From 9676382bb5eae1279a7036bcf334ebe99c8236f8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 12:15:14 -0700 Subject: [PATCH] feat(studio): compose FlatMotionSection from Timing and the flat effect-card list --- .../propertyPanelFlatMotionSection.test.tsx | 117 +++++++++++++++++- .../editor/propertyPanelFlatMotionSection.tsx | 99 +++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx index e1d87ed3c..2f5a47357 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx @@ -3,7 +3,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { FlatTimingRow } from "./propertyPanelFlatMotionSection"; +import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection"; import type { DomEditSelection } from "./domEditing"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -102,3 +102,118 @@ describe("FlatTimingRow", () => { act(() => root.unmount()); }); }); + +describe("FlatMotionSection", () => { + it("renders Timing when showTiming is true and the effect list when showEffects is true", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Start"); + expect(host.textContent).toContain("power2.out"); + act(() => root.unmount()); + }); + + it("omits Timing entirely when showTiming is false", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("Start"); + act(() => root.unmount()); + }); + + it("omits the effect list entirely when showEffects is false", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("power2.out"); + act(() => root.unmount()); + }); + + it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => { + const onAddAnimation = vi.fn(); + const { host, root } = renderInto( + , + ); + const buttons = () => Array.from(host.querySelectorAll("button")); + const addTrigger = buttons().find((b) => b.textContent === "+ Add effect"); + if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button"); + act(() => { + addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const animateButton = buttons().find((b) => b.textContent === "Animate"); + if (!animateButton) throw new Error("expected an 'Animate' method button"); + act(() => { + animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onAddAnimation).toHaveBeenCalledWith("to"); + // The menu closes back to the trigger after a selection. + expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 8312f1ae2..01fde1ffe 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -1,8 +1,12 @@ +import { useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { parseTimingValue } from "./propertyPanelTimingSection"; import { CommitField } from "./propertyPanelPrimitives"; +import { AnimationCard } from "./AnimationCard"; +import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; +import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; function deriveTimingFromAnimations( animations: GsapAnimation[], @@ -79,3 +83,98 @@ export function FlatTimingRow({ ); } + +export function FlatMotionSection({ + element, + animations, + showTiming, + showEffects, + multipleTimelines, + unsupportedTimelinePattern, + onSetAttribute, + onAddAnimation, + ...callbacks +}: { + element: DomEditSelection; + animations: GsapAnimation[]; + showTiming: boolean; + showEffects: boolean; + multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; + onSetAttribute: (attr: string, value: string) => void | Promise; + onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; +} & GsapAnimationEditCallbacks) { + const [addMenuOpen, setAddMenuOpen] = useState(false); + + return ( +
+ {showTiming && ( + + )} + {showEffects && ( + <> + {multipleTimelines && ( +

+ This file has multiple GSAP timelines. Animation editing is disabled to prevent data + loss — consolidate into a single timeline to enable editing. +

+ )} + {unsupportedTimelinePattern && ( +

+ This timeline uses a computed key the editor can't resolve statically. +

+ )} + {!multipleTimelines && !unsupportedTimelinePattern && ( +
+ {animations.map((anim, index) => ( + + ))} +
+ {addMenuOpen ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} + +
+ ) : ( + + )} +
+
+ )} + + )} +
+ ); +}