mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user