mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): animate flat inspector group expand/collapse
Adds a fast (120ms) CSS entrance animation for flat inspector accordion group headers/body, gated to the group actually toggling (not derived from remounting alone) to avoid a Chromium reflow quirk that otherwise replays the animation on untouched collapsed siblings.
This commit is contained in:
@@ -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<string | null>(null);
|
||||
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
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
|
||||
<div
|
||||
className={`${openGroup.id === justToggledId ? "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>
|
||||
@@ -510,6 +536,7 @@ export function PropertyPanelFlat({
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={g.id === justToggledId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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()} />,
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user