diff --git a/ui/components/shadcn/select/select.test.tsx b/ui/components/shadcn/select/select.test.tsx
new file mode 100644
index 0000000000..366466f425
--- /dev/null
+++ b/ui/components/shadcn/select/select.test.tsx
@@ -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(
+ ,
+ );
+}
+
+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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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",
+ );
+ });
+});
diff --git a/ui/components/shadcn/select/select.tsx b/ui/components/shadcn/select/select.tsx
index 76099df534..b0d64bd5c1 100644
--- a/ui/components/shadcn/select/select.tsx
+++ b/ui/components/shadcn/select/select.tsx
@@ -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({
+ isClosing: false,
+});
+
const stopWheelPropagation = (event: WheelEvent) => {
event.stopPropagation();
};
function Select({
allowDeselect = false,
+ open,
+ defaultOpen,
+ onOpenChange,
...props
}: ComponentProps & {
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 | 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 (
-
+
+
+
);
}
@@ -57,12 +131,15 @@ function SelectTrigger({
size?: "sm" | "default";
iconSize?: "sm" | "default";
}) {
+ const { isClosing } = useContext(SelectMotionContext);
+
return (
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({
& {
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({