feat(studio): compose FlatMotionSection from Timing and the flat effect-card list

This commit is contained in:
Vance Ingalls
2026-07-09 12:15:14 -07:00
parent b53db878a4
commit 59e178019c
2 changed files with 215 additions and 1 deletions
@@ -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(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming={false}
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
expect(host.textContent).not.toContain("Start");
act(() => root.unmount());
});
it("omits the effect list entirely when showEffects is false", () => {
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
} as never,
]}
showTiming
showEffects={false}
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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(
<FlatMotionSection
element={baseElement()}
animations={[]}
showTiming
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={onAddAnimation}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
/>,
);
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());
});
});
@@ -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({
</div>
);
}
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<void>;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
} & GsapAnimationEditCallbacks) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
return (
<div className="space-y-3">
{showTiming && (
<FlatTimingRow element={element} animations={animations} onSetAttribute={onSetAttribute} />
)}
{showEffects && (
<>
{multipleTimelines && (
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This file has multiple GSAP timelines. Animation editing is disabled to prevent data
loss consolidate into a single timeline to enable editing.
</p>
)}
{unsupportedTimelinePattern && (
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This timeline uses a computed key the editor can&apos;t resolve statically.
</p>
)}
{!multipleTimelines && !unsupportedTimelinePattern && (
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
flat
{...callbacks}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
</div>
)}
</>
)}
</div>
);
}