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
48 changes: 1 addition & 47 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ vi.mock("../../contexts/StudioContext", async () => {

afterEach(() => {
document.body.innerHTML = "";
// usePersistedPinnedGroups persists to localStorage; clear it so a pinned
// group from one test can't leak into the next (which would move a group out
// of the accordion and break an unrelated open-by-default assertion).
window.localStorage.clear();
vi.doUnmock("./manualEditingAvailability");
vi.resetModules();
});
Expand Down Expand Up @@ -813,47 +809,6 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
);
});

describe("PropertyPanel — pinning", () => {
it(
"renders a pinned group first, always open, above the PinnedZoneDivider",
async () => {
const { host, root } = await renderPanel(true);
// Pin the Text group via its pin button.
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
if (!pinButton) throw new Error("expected a pin button on the open Text group");
act(() => pinButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));

const pinnedRow = host.querySelector('[data-pinned-group="true"]');
expect(pinnedRow?.textContent).toContain("Text");
expect(pinnedRow?.textContent).toContain("Pinned");

// The divider must appear after the pinned zone.
const container = host.querySelector('[data-flat-panel-body="true"]');
const children = Array.from(container?.children ?? []);
const pinnedIndex = children.indexOf(pinnedRow as Element);
const dividerIndex = children.findIndex((el) => el.textContent?.includes("one open below"));
expect(pinnedIndex).toBeGreaterThanOrEqual(0);
expect(dividerIndex).toBeGreaterThan(pinnedIndex);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);

it(
"unpinning returns the group to its normal accordion stack position",
async () => {
const { host, root } = await renderPanel(true);
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const unpinButton = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
act(() => unpinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-pinned-group="true"]')).toBeNull();
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});

// design_handoff scrollable-open-section: collapsed headers before/after the
// open group render in normal document flow and never move (no sticky, no
// stacking offsets) — only the open group's own body content scrolls, in a
Expand Down Expand Up @@ -882,8 +837,7 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
return null;
});
// Filter to just the group entries (drop nulls from any divider, none
// expected here since no groups are pinned).
// Filter to just the group entries (drop any non-group nulls).
const groupTitles = titles.filter((t): t is string => t !== null);
expect(groupTitles).toHaveLength(6);
expect(groupTitles[0]).toContain("Text");
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
// Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and
// the values the legacy path already computed above (so they aren't derived
// twice). PropertyPanelFlat owns the one-open/pin group state.
// twice). PropertyPanelFlat owns the one-open group state.
return (
<PropertyPanelFlat
{...props}
Expand Down
39 changes: 8 additions & 31 deletions packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroupHeader, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
Expand All @@ -17,7 +17,6 @@ import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { useColorGradingController } from "./useColorGradingController";
import { usePersistedPinnedGroups } from "../../hooks/usePersistedPinnedGroups";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
Expand Down Expand Up @@ -52,7 +51,7 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
*
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
*
* The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
*/
Expand Down Expand Up @@ -252,7 +251,6 @@ export function PropertyPanelFlat({

const isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind);
const toggleOpen = (groupId: string) =>
setOpenGroupId((current) => (current === groupId ? "" : groupId));
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
Expand Down Expand Up @@ -311,9 +309,8 @@ export function PropertyPanelFlat({
const showMotionGroup = showMotionTiming || showMotionEffects;

// Ordered group descriptors — one per FlatGroup this panel renders, gated by
// the same conditions the inline JSX used. Partitioned into pinned/unpinned
// below so pinned groups render first (always open, no accordion) above the
// PinnedZoneDivider, with the rest in the one-open accordion beneath it.
// the same conditions the inline JSX used. Split below into before-open/
// open/after-open regions for the one-open accordion.
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
Expand Down Expand Up @@ -453,20 +450,17 @@ export function PropertyPanelFlat({
});
}

const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id));
const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id));

// Fixed-headers + scrollable-open-section layout (design_handoff
// scrollable-open-section, replaces the prior sticky-stacking mechanism):
// collapsed headers before/after the open group render in normal document
// flow and never move. Only the open group's own body content scrolls, in
// a dedicated region between the two fixed header stacks. When no group is
// open, every group is just a collapsed header — there's no scrollable
// middle region at all, since nothing is expanded.
const openIndex = unpinned.findIndex((g) => g.id === openGroupId);
const beforeOpen = openIndex === -1 ? unpinned : unpinned.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : unpinned[openIndex];
const afterOpen = openIndex === -1 ? [] : unpinned.slice(openIndex + 1);
const openIndex = groups.findIndex((g) => g.id === openGroupId);
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : groups[openIndex];
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);

return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
Expand All @@ -487,25 +481,12 @@ export function PropertyPanelFlat({
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-hidden">
{pinned.map((g) => (
<PinnedGroupRow
key={g.id}
title={g.title}
accessory={g.accessory}
onUnpin={() => togglePin(g.id)}
>
{g.content}
</PinnedGroupRow>
))}
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
{beforeOpen.map((g) => (
<FlatGroupHeader
key={g.id}
title={g.title}
isOpen={false}
isPinned={false}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
/>
))}
Expand All @@ -514,9 +495,7 @@ export function PropertyPanelFlat({
<FlatGroupHeader
title={openGroup.title}
isOpen
isPinned={false}
onToggleOpen={() => toggleOpen(openGroup.id)}
onTogglePin={() => togglePin(openGroup.id)}
accessory={openGroup.accessory}
/>
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
Expand All @@ -529,9 +508,7 @@ export function PropertyPanelFlat({
key={g.id}
title={g.title}
isOpen={false}
isPinned={false}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,9 @@ describe("PropertyPanelFlatFooter", () => {
// rounding gap) — is gone now that nothing above the footer is
// `position: sticky`. Live browser verification (p11 report) confirmed the
// boundary renders as a single clean hairline without it: whatever
// immediately precedes the footer (a collapsed FlatGroupHeader, the open
// group's scrollable body wrapper, or a PinnedGroupRow) already draws its
// own border-b in normal document flow, so the footer needs no border or
// seal of its own.
// immediately precedes the footer (a collapsed FlatGroupHeader, or the open
// group's scrollable body wrapper) already draws its own border-b in normal
// document flow, so the footer needs no border or seal of its own.
it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => {
const { host, root } = renderFooter({ onAskAgent: vi.fn() });
const footerRoot = host.firstElementChild as HTMLElement;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ export function PropertyPanelFlatFooter({
return (
// No border-t here: every possible element immediately above this footer
// in the new fixed-headers + scrollable-open-section layout (a collapsed
// FlatGroupHeader, the open group's scrollable body wrapper, or a
// PinnedGroupRow) already draws its own border-b in normal document flow
// — nothing here is `position: sticky` anymore, so there's no rounding
// seam to seal (see p11-scrollable-open-section-report.md).
// FlatGroupHeader, or the open group's scrollable body wrapper) already
// draws its own border-b in normal document flow — nothing here is
// `position: sticky` anymore, so there's no rounding seam to seal (see
// p11-scrollable-open-section-report.md).
<div className="flex items-center justify-between bg-panel-bg px-4 py-[11px]">
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import {
FlatSelectRow,
FlatSlider,
FlatToggle,
PinnedGroupRow,
PinnedZoneDivider,
} from "./propertyPanelFlatPrimitives";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -110,22 +108,12 @@ describe("FlatSegmentedRow", () => {
});

describe("FlatGroupHeader", () => {
it("renders the open header (name + pin + caret), with no sticky-related props required", () => {
it("renders the open header (name + caret), with no sticky-related props required", () => {
const onToggleOpen = vi.fn();
const onTogglePin = vi.fn();
const { host, root } = renderInto(
<FlatGroupHeader
title="Text"
isOpen
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={onTogglePin}
/>,
<FlatGroupHeader title="Text" isOpen onToggleOpen={onToggleOpen} />,
);
expect(host.textContent).toContain("Text");
const pin = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
act(() => pin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onTogglePin).toHaveBeenCalledTimes(1);
const collapse = host.querySelector<HTMLButtonElement>('button[title="Collapse"]');
act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
Expand All @@ -138,9 +126,7 @@ describe("FlatGroupHeader", () => {
<FlatGroupHeader
title="Style"
isOpen={false}
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={vi.fn()}
summary="fill none · 100%"
/>,
);
Expand All @@ -154,13 +140,7 @@ describe("FlatGroupHeader", () => {

it("renders no inline position styling in either state (collapsed headers never move)", () => {
const { host: collapsedHost, root: collapsedRoot } = renderInto(
<FlatGroupHeader
title="Layout"
isOpen={false}
isPinned={false}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
/>,
<FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
);
const row = collapsedHost.querySelector<HTMLButtonElement>(
'[data-flat-group-collapsed="true"]',
Expand All @@ -169,28 +149,14 @@ describe("FlatGroupHeader", () => {
act(() => collapsedRoot.unmount());

const { host: openHost, root: openRoot } = renderInto(
<FlatGroupHeader
title="Motion"
isOpen
isPinned={false}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
/>,
<FlatGroupHeader title="Motion" isOpen onToggleOpen={vi.fn()} />,
);
expect(openHost.textContent).toContain("Motion");
expect(openHost.querySelector("[style]")).toBeNull();
act(() => openRoot.unmount());
});
});

describe("PinnedZoneDivider", () => {
it("renders the 'one open below' label", () => {
const { host, root } = renderInto(<PinnedZoneDivider />);
expect(host.textContent).toContain("one open below");
act(() => root.unmount());
});
});

describe("FlatSlider", () => {
it("renders the default tier with a dim knob at the correct position", () => {
const { host, root } = renderInto(
Expand Down Expand Up @@ -486,21 +452,3 @@ describe("FlatToggle", () => {
act(() => root.unmount());
});
});

describe("PinnedGroupRow", () => {
it("renders a 'Pinned' badge, filled pin icon, and always shows children", () => {
const onUnpin = vi.fn();
const { host, root } = renderInto(
<PinnedGroupRow title="Motion" onUnpin={onUnpin}>
<div data-testid="body">body</div>
</PinnedGroupRow>,
);
expect(host.textContent).toContain("Pinned");
expect(host.textContent).toContain("Motion");
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
const unpin = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
act(() => unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onUnpin).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
Loading
Loading