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
61 changes: 59 additions & 2 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ function multiFieldTextElement() {
};
}

// Style-only fixture: no text fields (Text group must not render), but
// canEditStyles stays true (inherited from baseElement()) so the Style group
// is gated in.
function styleOnlyElement() {
return {
...baseElement(),
id: "stat-card",
selector: ".stat-card",
label: "Stat Card",
textFields: [],
inlineStyles: { "background-color": "#0D0C09" },
};
}

async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
Expand Down Expand Up @@ -185,9 +199,19 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
it(
"renders no Text group at all for a non-text element (bug 1)",
async () => {
// nonTextElement() inherits canEditStyles: true from baseElement(), so
// the Style group (Task 10) renders and opens by default here — the
// invariant under test is narrower than "no flat group at all": no
// group titled "Text" may appear, open or collapsed.
const { host, root } = await renderPanel(true, nonTextElement());
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
expect(host.querySelector('[data-flat-group-collapsed="true"]')).toBeNull();
const openTitle = host.querySelector(
'[data-flat-group-open="true"] .text-panel-text-0',
)?.textContent;
const collapsedTitles = Array.from(
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
).map((el) => el.textContent);
expect(openTitle).not.toBe("Text");
expect(collapsedTitles).not.toContain("Text");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
Expand All @@ -209,3 +233,36 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
RENDER_TIMEOUT_MS,
);
});

describe("PropertyPanel — Style group (flag on)", () => {
it(
"renders the Style group for a style-editable, non-text element",
async () => {
const { host, root } = await renderPanel(true, styleOnlyElement());
expect(host.textContent).toContain("Style");
expect(host.textContent).toContain("Fill");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);

it(
"one-open accordion: opening Style closes Text",
async () => {
// baseElement() is text-editable and has capabilities.canEditStyles:
// true, so both the Text and Style groups render for it.
const { host, root } = await renderPanel(true);
const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
expect(textGroup()?.textContent).toContain("Text");
const styleCollapsedRow = Array.from(
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
).find((el) => el.textContent?.includes("Style"));
if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(textGroup()?.textContent).not.toContain("Text");
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
return (
<PropertyPanelFlat
{...props}
key={element.id ?? element.selector}
element={element}
styles={styles}
sections={sections}
Expand Down
52 changes: 39 additions & 13 deletions packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
Expand Down Expand Up @@ -102,15 +103,25 @@ export function PropertyPanelFlat({
clipboardCopied: boolean;
onCopyElementInfo: () => void;
}) {
// Defaulting to "text" is harmless for a non-text element even though the
// Text FlatGroup won't render (nothing else reads openGroupId yet) — this
// only matters once a second FlatGroup exists (Plan 2+), at which point a
// non-text element should default-open that group instead.
const [openGroupId, setOpenGroupId] = useState<string>("text");
// Lazy initializer: pick whichever group actually renders for this element
// (Text if text-editable, else Style if style-editable, else none open) so a
// style-only element doesn't start with everything collapsed. Only runs on
// mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
// switching the selection re-mounts this component and re-derives the
// default instead of preserving stale state across unrelated elements.
const [openGroupId, setOpenGroupId] = useState<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);

const isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
const toggleOpen = (groupId: string) =>
setOpenGroupId((current) => (current === groupId ? "" : groupId));
const togglePin = (groupId: string) =>
setPinnedGroupIds((current) =>
current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId],
);

return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
Expand All @@ -136,14 +147,8 @@ export function PropertyPanelFlat({
title="Text"
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
isPinned={pinnedGroupIds.includes("text")}
onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))}
onTogglePin={() =>
setPinnedGroupIds((current) =>
current.includes("text")
? current.filter((id) => id !== "text")
: [...current, "text"],
)
}
onToggleOpen={() => toggleOpen("text")}
onTogglePin={() => togglePin("text")}
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
>
<FlatTextSection
Expand All @@ -159,6 +164,27 @@ export function PropertyPanelFlat({
</FlatGroup>
)}

{showEditableSections && (
<FlatGroup
title="Style"
isOpen={openGroupId === "style" || pinnedGroupIds.includes("style")}
isPinned={pinnedGroupIds.includes("style")}
onToggleOpen={() => toggleOpen("style")}
onTogglePin={() => togglePin("style")}
summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`}
>
<FlatStyleSection
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
</FlatGroup>
)}

{sections.timing && (
<TimingSection
element={element}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
FlatGroup,
FlatRow,
FlatSegmentedRow,
FlatSelectRow,
FlatSlider,
PinnedZoneDivider,
} from "./propertyPanelFlatPrimitives";

Expand Down Expand Up @@ -157,3 +159,130 @@ describe("PinnedZoneDivider", () => {
act(() => root.unmount());
});
});

describe("FlatSlider", () => {
it("renders the default tier with a dim knob at the correct position", () => {
const { host, root } = renderInto(
<FlatSlider
label="Layer blur"
value={0}
min={0}
max={40}
tier="default"
displayValue="0px"
onCommit={vi.fn()}
/>,
);
const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
expect(knob).not.toBeNull();
expect(knob?.className).toContain("bg-panel-text-4");
expect(knob?.style.left).toBe("0%");
const value = host.querySelector('[data-flat-slider-value="true"]');
expect(value?.className).toContain("text-panel-text-3");
expect(value?.textContent).toBe("0px");
act(() => root.unmount());
});

it("renders the explicitCustom tier with a filled track and bright knob", () => {
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={100}
min={0}
max={100}
tier="explicitCustom"
displayValue="100%"
onCommit={vi.fn()}
/>,
);
const fill = host.querySelector<HTMLElement>('[data-flat-slider-fill="true"]');
expect(fill?.style.width).toBe("100%");
const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
expect(knob?.className).toContain("bg-white");
act(() => root.unmount());
});

it("commits a value on track click, proportional to click position", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={50}
min={0}
max={100}
tier="explicitCustom"
displayValue="50%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }),
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
});
expect(onCommit).toHaveBeenCalledWith(50);
act(() => root.unmount());
});
});

describe("FlatSelectRow", () => {
it("renders the default tier with no reset button", () => {
const { host, root } = renderInto(
<FlatSelectRow
label="Blend"
value="normal"
options={["normal", "multiply", "screen"]}
tier="default"
onChange={vi.fn()}
/>,
);
const select = host.querySelector("select");
expect(select?.value).toBe("normal");
expect(host.querySelector('[data-flat-select-reset="true"]')).toBeNull();
act(() => root.unmount());
});

it("renders the explicitCustom tier with a reset button and fires onReset", () => {
const onReset = vi.fn();
const { host, root } = renderInto(
<FlatSelectRow
label="Shadow"
value="soft"
options={["none", "soft", "lift", "glow"]}
tier="explicitCustom"
onChange={vi.fn()}
onReset={onReset}
/>,
);
const select = host.querySelector<HTMLSelectElement>("select");
expect(select?.className).toContain("text-panel-accent");
const reset = host.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onReset).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});

it("fires onChange when the select value changes", () => {
const onChange = vi.fn();
const { host, root } = renderInto(
<FlatSelectRow
label="Overflow"
value="visible"
options={["visible", "hidden", "clip", "auto", "scroll"]}
tier="default"
onChange={onChange}
/>,
);
const select = host.querySelector<HTMLSelectElement>("select");
if (!select) throw new Error("expected a select");
act(() => {
select.value = "hidden";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith("hidden");
act(() => root.unmount());
});
});
Loading
Loading