mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2136 from heygen-com/studio-flat-11-expand-collapse-animation
feat(studio): animate flat inspector group expand/collapse
This commit is contained in:
@@ -899,3 +899,50 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
|
|||||||
RENDER_TIMEOUT_MS,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { resolveEditingSections } from "@hyperframes/core/editing";
|
||||||
import type { DomEditSelection } from "./domEditing";
|
import type { DomEditSelection } from "./domEditing";
|
||||||
import { isTextEditableSelection } from "./domEditing";
|
import { isTextEditableSelection } from "./domEditing";
|
||||||
@@ -235,6 +235,27 @@ export function PropertyPanelFlat({
|
|||||||
: "layout",
|
: "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
|
// Grade group state. Called unconditionally (React rules-of-hooks) even when
|
||||||
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
||||||
// which is only mounted when the section is active, PropertyPanelFlat is not
|
// which is only mounted when the section is active, PropertyPanelFlat is not
|
||||||
@@ -251,8 +272,18 @@ export function PropertyPanelFlat({
|
|||||||
|
|
||||||
const isTextEditable = isTextEditableSelection(element);
|
const isTextEditable = isTextEditableSelection(element);
|
||||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
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));
|
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) —
|
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
||||||
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
||||||
// range from animations when there's no explicit data-duration. Computed
|
// range from animations when there's no explicit data-duration. Computed
|
||||||
@@ -488,6 +519,7 @@ export function PropertyPanelFlat({
|
|||||||
isOpen={false}
|
isOpen={false}
|
||||||
onToggleOpen={() => toggleOpen(g.id)}
|
onToggleOpen={() => toggleOpen(g.id)}
|
||||||
summary={g.summary}
|
summary={g.summary}
|
||||||
|
animateEntrance={justToggledIds.includes(g.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{openGroup && (
|
{openGroup && (
|
||||||
@@ -497,8 +529,11 @@ export function PropertyPanelFlat({
|
|||||||
isOpen
|
isOpen
|
||||||
onToggleOpen={() => toggleOpen(openGroup.id)}
|
onToggleOpen={() => toggleOpen(openGroup.id)}
|
||||||
accessory={openGroup.accessory}
|
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}
|
{openGroup.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,6 +545,7 @@ export function PropertyPanelFlat({
|
|||||||
isOpen={false}
|
isOpen={false}
|
||||||
onToggleOpen={() => toggleOpen(g.id)}
|
onToggleOpen={() => toggleOpen(g.id)}
|
||||||
summary={g.summary}
|
summary={g.summary}
|
||||||
|
animateEntrance={justToggledIds.includes(g.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,6 +138,36 @@ describe("FlatGroupHeader", () => {
|
|||||||
act(() => root.unmount());
|
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)", () => {
|
it("renders no inline position styling in either state (collapsed headers never move)", () => {
|
||||||
const { host: collapsedHost, root: collapsedRoot } = renderInto(
|
const { host: collapsedHost, root: collapsedRoot } = renderInto(
|
||||||
<FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
|
<FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
|
||||||
|
|||||||
@@ -149,12 +149,22 @@ export function FlatGroupHeader({
|
|||||||
onToggleOpen,
|
onToggleOpen,
|
||||||
accessory,
|
accessory,
|
||||||
summary,
|
summary,
|
||||||
|
animateEntrance,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggleOpen: () => void;
|
onToggleOpen: () => void;
|
||||||
accessory?: ReactNode;
|
accessory?: ReactNode;
|
||||||
summary?: string;
|
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) {
|
if (!isOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -162,7 +172,7 @@ export function FlatGroupHeader({
|
|||||||
type="button"
|
type="button"
|
||||||
data-flat-group-collapsed="true"
|
data-flat-group-collapsed="true"
|
||||||
onClick={onToggleOpen}
|
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="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
|
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
|
||||||
@@ -186,7 +196,9 @@ export function FlatGroupHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
||||||
<span className="flex items-center gap-2.5 text-panel-text-5">
|
<span className="flex items-center gap-2.5 text-panel-text-5">
|
||||||
{accessory}
|
{accessory}
|
||||||
|
|||||||
@@ -447,3 +447,25 @@ body {
|
|||||||
animation: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user