diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 73fc90905..9bd267983 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { resolveEditingSections } from "@hyperframes/core/editing"; import type { DomEditSelection } from "./domEditing"; import { isTextEditableSelection } from "./domEditing"; @@ -235,6 +235,24 @@ export function PropertyPanelFlat({ : "layout", ); + // Tracks which single group is actively transitioning, so its header/body + // gets the fast entrance animation (hf-flat-group-enter) and no one else's + // does. Deliberately NOT derived from remounting alone: FlatGroupHeader + // instances are keyed by group id and React normally preserves them across + // re-renders, but toggling a non-adjacent group still shifts the untouched + // collapsed siblings between the before/after-open slices below, and + // Chromium restarts a CSS animation on that kind of position shift even + // though nothing about the sibling actually changed. Gating on this id + // (cleared shortly after the 120ms CSS animation finishes) keeps the + // animation scoped to only the group that actually just toggled. + const [justToggledId, setJustToggledId] = useState(null); + const justToggledTimeoutRef = useRef | null>(null); + useEffect(() => { + return () => { + if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); + }; + }, []); + // Grade group state. Called unconditionally (React rules-of-hooks) even when // sections.colorGrading is false — unlike the legacy ColorGradingSection, // which is only mounted when the section is active, PropertyPanelFlat is not @@ -251,8 +269,12 @@ export function PropertyPanelFlat({ const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; - const toggleOpen = (groupId: string) => + const toggleOpen = (groupId: string) => { setOpenGroupId((current) => (current === groupId ? "" : groupId)); + setJustToggledId(groupId); + if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current); + justToggledTimeoutRef.current = setTimeout(() => setJustToggledId(null), 200); + }; // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — // must agree with Motion's Timing row (FlatTimingRow), which infers the // range from animations when there's no explicit data-duration. Computed @@ -488,6 +510,7 @@ export function PropertyPanelFlat({ isOpen={false} onToggleOpen={() => toggleOpen(g.id)} summary={g.summary} + animateEntrance={g.id === justToggledId} /> ))} {openGroup && ( @@ -497,8 +520,11 @@ export function PropertyPanelFlat({ isOpen onToggleOpen={() => toggleOpen(openGroup.id)} accessory={openGroup.accessory} + animateEntrance={openGroup.id === justToggledId} /> -
+
{openGroup.content}
@@ -510,6 +536,7 @@ export function PropertyPanelFlat({ isOpen={false} onToggleOpen={() => toggleOpen(g.id)} summary={g.summary} + animateEntrance={g.id === justToggledId} /> ))} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index b92998a04..1d76262bf 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -138,6 +138,36 @@ describe("FlatGroupHeader", () => { act(() => root.unmount()); }); + it("applies the entrance animation class to both states, only when animateEntrance is set", () => { + const { host: openHost, root: openRoot } = renderInto( + , + ); + expect(openHost.firstElementChild?.className).toContain("hf-flat-group-enter"); + act(() => openRoot.unmount()); + + const { host: collapsedHost, root: collapsedRoot } = renderInto( + , + ); + const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.className).toContain("hf-flat-group-enter"); + act(() => collapsedRoot.unmount()); + }); + + it("omits the entrance animation class in both states when animateEntrance is not set", () => { + const { host: openHost, root: openRoot } = renderInto( + , + ); + expect(openHost.firstElementChild?.className).not.toContain("hf-flat-group-enter"); + act(() => openRoot.unmount()); + + const { host: collapsedHost, root: collapsedRoot } = renderInto( + , + ); + const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.className).not.toContain("hf-flat-group-enter"); + act(() => collapsedRoot.unmount()); + }); + it("renders no inline position styling in either state (collapsed headers never move)", () => { const { host: collapsedHost, root: collapsedRoot } = renderInto( , diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 6ca3ea8bf..5da851d2b 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -149,12 +149,22 @@ export function FlatGroupHeader({ onToggleOpen, accessory, summary, + animateEntrance, }: { title: string; isOpen: boolean; onToggleOpen: () => void; accessory?: ReactNode; summary?: string; + /** Play the fast entrance animation on this render — set only for the one + * group actually transitioning (see PropertyPanelFlat's justToggledId). + * Not derived from `isOpen`/remounting alone: React's key-based diffing + * can still shift an unrelated collapsed sibling's position in the + * before/after-open arrays (e.g. when the newly opened group isn't + * adjacent to the previously open one), and Chromium restarts a CSS + * entrance animation on such a position change even though nothing about + * that sibling actually changed — gating explicitly avoids that replay. */ + animateEntrance?: boolean; }) { if (!isOpen) { return ( @@ -162,7 +172,7 @@ export function FlatGroupHeader({ type="button" data-flat-group-collapsed="true" onClick={onToggleOpen} - className="flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left" + className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left`} > {title} @@ -186,7 +196,9 @@ export function FlatGroupHeader({ } return ( -
+
{title} {accessory} diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css index 99ebd0cd2..50e3a02fe 100644 --- a/packages/studio/src/styles/studio.css +++ b/packages/studio/src/styles/studio.css @@ -447,3 +447,25 @@ body { animation: none; } } + +/* Flat inspector group header/body entrance (FlatGroupHeader) */ +@keyframes hf-flat-group-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.hf-flat-group-enter { + animation: hf-flat-group-in 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .hf-flat-group-enter { + animation: none; + } +}