From b93e3f9d04361eb78052ad4b5bcd830aed068f2c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 5 Jun 2026 13:04:16 +0200 Subject: [PATCH] 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 --- ui/components/shadcn/select/select.test.tsx | 222 ++++++++++++++++++++ ui/components/shadcn/select/select.tsx | 101 ++++++++- 2 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 ui/components/shadcn/select/select.test.tsx 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({