Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -899,3 +899,50 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
RENDER_TIMEOUT_MS,
);
});

describe("PropertyPanel — flat group entrance animation scoping (fix round)", () => {
it(
"animates only the opening group and the implicitly-closed group on a non-adjacent toggle, never untouched siblings",
async () => {
const { host, root } = await renderPanel(true, sixGroupElement());
// sixGroupElement() opens Text by default; jump straight to Motion
// (skipping over Style/Layout) first, matching the Plan 11 worked
// example, then jump back to Text — non-adjacent from Motion, again
// skipping over Style/Layout. This is the exact array-slice-position-
// shift scenario the justToggledIds mechanism exists to guard: Style
// and Layout shift position in the before/after-open slices on both
// toggles even though neither of them is the group being toggled.
openFlatGroup(host, "Motion");
expect(openGroupText(host)).toContain("Motion");
openFlatGroup(host, "Text");
expect(openGroupText(host)).toContain("Text");

const collapsedRowByTitle = (title: string) => {
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find(
(el) => el.textContent?.includes(title),
);
if (!row) throw new Error(`expected a collapsed ${title} row`);
return row;
};

// Untouched, non-adjacent siblings must NOT receive the entrance class,
// even though they shifted position in the collapsed-header list.
expect(collapsedRowByTitle("Style").classList.contains("hf-flat-group-enter")).toBe(false);
expect(collapsedRowByTitle("Layout").classList.contains("hf-flat-group-enter")).toBe(false);
expect(collapsedRowByTitle("Grade").classList.contains("hf-flat-group-enter")).toBe(false);
expect(collapsedRowByTitle("Media").classList.contains("hf-flat-group-enter")).toBe(false);

// Motion — open a moment ago, just implicitly closed by the click on
// Text — must still play its own collapse-entrance animation (Finding 1).
expect(collapsedRowByTitle("Motion").classList.contains("hf-flat-group-enter")).toBe(true);

// Text — the group actually clicked open — must animate too.
const openWrapper = host.querySelector('[data-flat-group-open="true"]');
if (!openWrapper) throw new Error("expected the open-group wrapper");
expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();

act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
42 changes: 39 additions & 3 deletions packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -235,6 +235,27 @@ export function PropertyPanelFlat({
: "layout",
);

// Tracks which group(s) are actively transitioning this toggle cycle, so
// their 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 these ids (cleared shortly after the 120ms CSS animation
// finishes) keeps the animation scoped to only the groups that actually
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
// AND whichever group was open immediately before the click and got
// implicitly closed by it — both freshly-mounted headers need to animate.
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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
Expand All @@ -251,8 +272,18 @@ 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) => {
// Capture what was open BEFORE this click (this render's closure over
// openGroupId), so the group that's about to be implicitly closed can be
// tracked too — not just the one the user clicked.
const previousOpenGroupId = openGroupId;
setOpenGroupId((current) => (current === groupId ? "" : groupId));
const implicitlyClosedId =
previousOpenGroupId && previousOpenGroupId !== groupId ? previousOpenGroupId : null;
setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]);
if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 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
Expand Down Expand Up @@ -488,6 +519,7 @@ export function PropertyPanelFlat({
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
))}
{openGroup && (
Expand All @@ -497,8 +529,11 @@ export function PropertyPanelFlat({
isOpen
onToggleOpen={() => toggleOpen(openGroup.id)}
accessory={openGroup.accessory}
animateEntrance={justToggledIds.includes(openGroup.id)}
/>
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
<div
className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3`}
>
{openGroup.content}
</div>
</div>
Expand All @@ -510,6 +545,7 @@ export function PropertyPanelFlat({
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FlatGroupHeader title="Text" isOpen onToggleOpen={vi.fn()} animateEntrance />,
);
expect(openHost.firstElementChild?.className).toContain("hf-flat-group-enter");
act(() => openRoot.unmount());

const { host: collapsedHost, root: collapsedRoot } = renderInto(
<FlatGroupHeader title="Style" isOpen={false} onToggleOpen={vi.fn()} animateEntrance />,
);
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(
<FlatGroupHeader title="Text" isOpen onToggleOpen={vi.fn()} />,
);
expect(openHost.firstElementChild?.className).not.toContain("hf-flat-group-enter");
act(() => openRoot.unmount());

const { host: collapsedHost, root: collapsedRoot } = renderInto(
<FlatGroupHeader title="Style" isOpen={false} onToggleOpen={vi.fn()} />,
);
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(
<FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,30 @@ 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(s) actually transitioning (see PropertyPanelFlat's justToggledIds).
* 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 (
<button
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`}
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
Expand All @@ -186,7 +196,9 @@ export function FlatGroupHeader({
}

return (
<div className="flex min-h-10 flex-shrink-0 items-center justify-between bg-panel-bg px-4">
<div
className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 flex-shrink-0 items-center justify-between bg-panel-bg px-4`}
>
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
<span className="flex items-center gap-2.5 text-panel-text-5">
{accessory}
Expand Down
22 changes: 22 additions & 0 deletions packages/studio/src/styles/studio.css
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,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;
}
}
Loading