feat(ui): add Select open close microinteraction

- Add visible Select close motion without open-state conflicts
- Preserve reduced-motion behavior
- Cover controlled and uncontrolled close flows
This commit is contained in:
Alan Buscaglia
2026-06-05 13:04:16 +02:00
parent ef4d05a782
commit b93e3f9d04
2 changed files with 314 additions and 9 deletions
+222
View File
@@ -0,0 +1,222 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
vi.useRealTimers();
});
function renderTypeSelect({ open = false }: { open?: boolean } = {}) {
return render(
<Select defaultValue="all" open={open} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
}
describe("Select", () => {
it("renders an open dropdown with selectable options", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
// Then
expect(
within(listbox).getByRole("option", { name: "All Types" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Manual" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Scheduled" }),
).toBeVisible();
});
it("uses robust trigger transitions for hover, focus, and chevron state", () => {
// Given
renderTypeSelect();
// When
const trigger = screen.getByRole("combobox", { name: "All Types" });
const icon = trigger.querySelector("svg");
// Then
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-[rotate]",
"duration-200",
"ease-out",
"group-data-[state=open]:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("preserves the Radix open data-state model", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
const content = listbox.closest("[data-slot='select-content']");
// Then
expect(content).toHaveAttribute("data-state", "open");
});
it("keeps content mounted briefly with a closing state", async () => {
// Given
const { rerender } = renderTypeSelect({ open: true });
expect(screen.getByRole("listbox")).toBeVisible();
// When
rerender(
<Select defaultValue="all" open={false} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(trigger).toHaveAttribute("data-closing", "true");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("keeps uncontrolled content mounted briefly after selecting an option", async () => {
// Given
const user = userEvent.setup();
render(
<Select defaultOpen defaultValue="all" onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
expect(screen.getByRole("listbox")).toBeVisible();
// When
await user.click(screen.getByRole("option", { name: "Manual" }));
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(content).toHaveClass(
"animate-out",
"fade-out-0",
"zoom-out-95",
"pointer-events-none",
"duration-100",
"ease-in",
);
expect(content).not.toHaveClass(
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
);
expect(trigger).toHaveAttribute("data-closing", "true");
});
it("uses explicit open and close motion classes", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0",
"data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:zoom-out-95",
"duration-200",
"ease-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy dropdown motion for reduced motion", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+92 -9
View File
@@ -2,20 +2,90 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ComponentProps, type WheelEvent } from "react";
import {
ComponentProps,
createContext,
useContext,
useEffect,
useRef,
useState,
type WheelEvent,
} from "react";
import { cn } from "@/lib/utils";
const SELECT_CLOSE_ANIMATION_MS = 100;
interface SelectMotionContextValue {
isClosing: boolean;
}
const SelectMotionContext = createContext<SelectMotionContextValue>({
isClosing: false,
});
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
function Select({
allowDeselect = false,
open,
defaultOpen,
onOpenChange,
...props
}: ComponentProps<typeof SelectPrimitive.Root> & {
allowDeselect?: boolean;
}) {
const isControlled = open !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(
defaultOpen ?? false,
);
const requestedOpen = isControlled ? open : uncontrolledOpen;
const [renderedOpen, setRenderedOpen] = useState(requestedOpen);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (requestedOpen) {
setIsClosing(false);
setRenderedOpen(true);
return;
}
if (!renderedOpen) {
setIsClosing(false);
return;
}
setIsClosing(true);
closeTimerRef.current = setTimeout(() => {
setRenderedOpen(false);
setIsClosing(false);
closeTimerRef.current = null;
}, SELECT_CLOSE_ANIMATION_MS);
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [requestedOpen, renderedOpen]);
const handleOpenChange = (nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
const handleValueChange = (nextValue: string) => {
if (allowDeselect && props.value === nextValue) {
// Single-select with deselect
@@ -27,11 +97,15 @@ function Select({
};
return (
<SelectPrimitive.Root
data-slot="select"
{...props}
onValueChange={handleValueChange}
/>
<SelectMotionContext.Provider value={{ isClosing }}>
<SelectPrimitive.Root
data-slot="select"
{...props}
open={renderedOpen}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
/>
</SelectMotionContext.Provider>
);
}
@@ -57,12 +131,15 @@ function SelectTrigger({
size?: "sm" | "default";
iconSize?: "sm" | "default";
}) {
const { isClosing } = useContext(SelectMotionContext);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-closing={isClosing ? "true" : undefined}
className={cn(
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -71,7 +148,8 @@ function SelectTrigger({
<SelectPrimitive.Icon asChild>
<ChevronDownIcon
className={cn(
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
"text-bg-button-secondary shrink-0 opacity-70 transition-[rotate] duration-200 ease-out motion-reduce:rotate-0 motion-reduce:transition-none",
isClosing ? "rotate-0" : "group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
)}
aria-hidden="true"
@@ -92,6 +170,7 @@ function SelectContent({
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
}) {
const { isClosing } = useContext(SelectMotionContext);
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-select-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
@@ -101,8 +180,12 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-closing={isClosing ? "true" : undefined}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border",
"bg-popover text-popover-foreground data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
isClosing
? "animate-out fade-out-0 zoom-out-95 pointer-events-none duration-100 ease-in"
: "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,