fix(ui): animate collapsible height and degrade tree motion under reduced motion

This commit is contained in:
Alan Buscaglia
2026-06-11 16:47:55 +02:00
parent 137fb6388f
commit c41e7e735b
4 changed files with 55 additions and 21 deletions
+2 -11
View File
@@ -24,16 +24,8 @@ describe("Collapsible", () => {
expect(content).toHaveAttribute("data-slot", "collapsible-content");
expect(content).toHaveClass(
"overflow-hidden",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:slide-in-from-top-1",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:slide-out-to-top-1",
"data-[state=closed]:duration-150",
"data-[state=closed]:ease-in",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:animate-collapsible-up",
);
});
@@ -52,7 +44,6 @@ describe("Collapsible", () => {
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
+4 -5
View File
@@ -29,11 +29,10 @@ function CollapsibleContent({
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
className={cn(
"overflow-hidden duration-200 ease-out",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1",
"data-[state=closed]:duration-150 data-[state=closed]:ease-in",
"motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
"overflow-hidden",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:animate-collapsible-up",
"motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
+25 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ChevronRightIcon } from "lucide-react";
import { KeyboardEvent } from "react";
@@ -14,6 +14,24 @@ import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIndicator } from "./tree-status-indicator";
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
export function getTreeChildrenMotion(shouldReduceMotion: boolean) {
if (shouldReduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0, ease: "easeInOut" as const },
};
}
return {
initial: { opacity: 0, height: 0 },
animate: { opacity: 1, height: "auto" as const },
exit: { opacity: 0, height: 0 },
transition: { duration: 0.2, ease: "easeInOut" as const },
};
}
/**
* TreeNode component for rendering expandable nodes with children.
*
@@ -36,6 +54,8 @@ export function TreeNode({
renderItem,
enableSelectChildren,
}: TreeNodeProps) {
const shouldReduceMotion = useReducedMotion();
const childrenMotion = getTreeChildrenMotion(!!shouldReduceMotion);
const isExpanded = expandedIds.includes(item.id);
const isSelected = selectedIds.includes(item.id);
const statusIcon =
@@ -166,10 +186,10 @@ export function TreeNode({
{isExpanded && (
<motion.ul
key={`children-${item.id}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
initial={childrenMotion.initial}
animate={childrenMotion.animate}
exit={childrenMotion.exit}
transition={childrenMotion.transition}
className="mt-1 space-y-1 overflow-hidden"
role="group"
>
@@ -1,8 +1,32 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getTreeChildrenMotion } from "./tree-node";
import { TreeView } from "./tree-view";
describe("getTreeChildrenMotion", () => {
it("animates height when motion is allowed", () => {
// Given / When
const motion = getTreeChildrenMotion(false);
// Then
expect(motion.initial).toHaveProperty("height", 0);
expect(motion.animate).toHaveProperty("height", "auto");
expect(motion.transition.duration).toBeGreaterThan(0);
});
it("degrades to opacity-only with no height under reduced motion", () => {
// Given / When
const motion = getTreeChildrenMotion(true);
// Then
expect(motion.initial).not.toHaveProperty("height");
expect(motion.animate).not.toHaveProperty("height");
expect(motion.exit).not.toHaveProperty("height");
expect(motion.transition.duration).toBe(0);
});
});
const treeData = [
{
id: "org-1",