diff --git a/ui/components/shadcn/search-input/search-input.test.tsx b/ui/components/shadcn/search-input/search-input.test.tsx index 1daca44e79..b1f25cc7bd 100644 --- a/ui/components/shadcn/search-input/search-input.test.tsx +++ b/ui/components/shadcn/search-input/search-input.test.tsx @@ -1,7 +1,31 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { SearchInput } from "./search-input"; +import { getClearButtonMotion, SearchInput } from "./search-input"; + +describe("getClearButtonMotion", () => { + it("animates with scale when motion is allowed", () => { + // Given / When + const motion = getClearButtonMotion(false); + + // Then + expect(motion.animate).toHaveProperty("scale", 1); + expect(motion.initial).toHaveProperty("scale"); + expect(motion.exit).toHaveProperty("scale"); + expect(motion.transition.duration).toBeGreaterThan(0); + }); + + it("degrades to opacity-only with no scale under reduced motion", () => { + // Given / When + const motion = getClearButtonMotion(true); + + // Then + expect(motion.initial).not.toHaveProperty("scale"); + expect(motion.animate).not.toHaveProperty("scale"); + expect(motion.exit).not.toHaveProperty("scale"); + expect(motion.transition.duration).toBe(0); + }); +}); describe("SearchInput", () => { it("animates input focus, icon color, and clear button entry", () => { diff --git a/ui/components/shadcn/search-input/search-input.tsx b/ui/components/shadcn/search-input/search-input.tsx index 2cb9be1736..97864ad0f5 100644 --- a/ui/components/shadcn/search-input/search-input.tsx +++ b/ui/components/shadcn/search-input/search-input.tsx @@ -1,7 +1,7 @@ "use client"; import { cva, type VariantProps } from "class-variance-authority"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { SearchIcon, XCircle } from "lucide-react"; import { ComponentProps, forwardRef } from "react"; @@ -67,6 +67,24 @@ export interface SearchInputProps onClear?: () => void; } +export function getClearButtonMotion(shouldReduceMotion: boolean) { + if (shouldReduceMotion) { + return { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0, ease: "easeOut" as const }, + }; + } + + return { + initial: { opacity: 0, scale: 0.95 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.95 }, + transition: { duration: 0.25, ease: "easeOut" as const }, + }; +} + const SearchInput = forwardRef( ( { @@ -84,6 +102,8 @@ const SearchInput = forwardRef( const iconPosition = iconPositionMap[size || "default"]; const clearButtonPosition = clearButtonPositionMap[size || "default"]; const hasValue = value && String(value).length > 0; + const shouldReduceMotion = useReducedMotion(); + const clearButtonMotion = getClearButtonMotion(!!shouldReduceMotion); return (
@@ -110,10 +130,10 @@ const SearchInput = forwardRef( type="button" data-slot="search-input-clear" aria-label="Clear search" - initial={{ opacity: 0, scale: 0.95 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - transition={{ duration: 0.25, ease: "easeOut" }} + initial={clearButtonMotion.initial} + animate={clearButtonMotion.animate} + exit={clearButtonMotion.exit} + transition={clearButtonMotion.transition} onClick={onClear} className={cn( "text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors duration-250 ease-out focus:outline-none motion-reduce:transition-none",