fix(ui): degrade search-input clear button motion under reduced motion

This commit is contained in:
Alan Buscaglia committed 2026-06-11 16:47:46 +02:00
1 parent 44e36421b1
commit 4648bb29d3
2 files changed
+50 -6

No files matched your search

@@ -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", () => {
@@ -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<HTMLInputElement, SearchInputProps>(
(
{
@@ -84,6 +102,8 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
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 (
<div className={cn(searchInputWrapperVariants({ size }))}>
@@ -110,10 +130,10 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
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",