Merge branch 'fix/studio-sfx-u13' into fix/studio-ui-audit-fixes

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-15 19:32:24 -04:00
4 changed files with 237 additions and 10 deletions
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
// @vitest-environment happy-dom
import { act, createElement, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../../player";
import type { DomEditSelection } from "./domEditing";
import {
PropertyPanel,
buildInsetClipPathSides,
buildStrokeStyleUpdates,
buildStrokeWidthStyleUpdates,
@@ -13,6 +19,186 @@ import {
setCssFilterFunctionPx,
} from "./PropertyPanel";
import { isSelectedElementHidden } from "./propertyPanelHelpers";
import { StyleSections } from "./propertyPanelStyleSections";
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ showToast: () => undefined }),
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function makeSelection(
capabilityOverrides: Partial<DomEditSelection["capabilities"]>,
): DomEditSelection {
return {
element: document.createElement("div"),
id: "selected",
selector: "#selected",
selectorIndex: 0,
label: "Selected element",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: null,
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
...capabilityOverrides,
},
};
}
function renderInDocument(node: ReactNode): {
host: HTMLDivElement;
unmount: () => void;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(node);
});
return {
host,
unmount: () => {
act(() => root.unmount());
host.remove();
},
};
}
function renderPropertyPanel(element: DomEditSelection) {
return renderInDocument(
createElement(PropertyPanel, {
projectId: "project",
projectDir: null,
assets: [],
element,
onClearSelection: vi.fn(),
onSetStyle: vi.fn(),
onSetAttribute: vi.fn(),
onSetAttributeLive: vi.fn(),
onSetHtmlAttribute: vi.fn(),
onSetManualOffset: vi.fn(),
onSetManualSize: vi.fn(),
onSetManualRotation: vi.fn(),
onSetText: vi.fn(),
onSetTextFieldStyle: vi.fn(),
onAddTextField: vi.fn(),
onRemoveTextField: vi.fn(),
}),
);
}
function renderStyleSections(element: DomEditSelection) {
return renderInDocument(
createElement(StyleSections, {
projectId: "project",
element,
styles: {},
assets: [],
onSetStyle: vi.fn(),
}),
);
}
describe("PropertyPanel disabled control reasons", () => {
it("renders the capability reason on a disabled Layout section", () => {
const reason = "This element belongs to a locked composition.";
const panel = renderPropertyPanel(
makeSelection({
canEditStyles: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: reason,
}),
);
const layout = panel.host.querySelector('[data-panel-section="layout"]');
expect(layout?.querySelector("[data-disabled-reason]")?.textContent).toBe(reason);
panel.unmount();
});
it("renders no disabled reason note for an enabled selection", () => {
const panel = renderPropertyPanel(makeSelection({}));
expect(panel.host.querySelector('[data-panel-section="layout"] [data-disabled-reason]')).toBe(
null,
);
panel.unmount();
});
it("renders no empty disabled reason chrome when no reason is available", () => {
const panel = renderPropertyPanel(
makeSelection({
canEditStyles: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
}),
);
expect(panel.host.querySelector('[data-panel-section="layout"] [data-disabled-reason]')).toBe(
null,
);
panel.unmount();
});
it("renders the capability reason on a disabled Fill section", () => {
const reason = "This element belongs to a locked composition.";
const panel = renderStyleSections(
makeSelection({
canEditStyles: false,
reasonIfDisabled: reason,
}),
);
const fill = panel.host.querySelector('[data-panel-section="fill"]');
expect(fill?.querySelector("[data-disabled-reason]")?.textContent).toBe(reason);
panel.unmount();
});
it("renders no stale disabled reason note on an enabled Fill section", () => {
const panel = renderStyleSections(
makeSelection({
canEditStyles: true,
reasonIfDisabled: "This reason is not relevant while style editing is enabled.",
}),
);
expect(panel.host.querySelector('[data-panel-section="fill"] [data-disabled-reason]')).toBe(
null,
);
panel.unmount();
});
it("renders no empty disabled reason chrome on Fill when no reason is available", () => {
const panel = renderStyleSections(
makeSelection({
canEditStyles: false,
}),
);
expect(panel.host.querySelector('[data-panel-section="fill"] [data-disabled-reason]')).toBe(
null,
);
panel.unmount();
});
});
describe("PropertyPanel style helpers", () => {
it("normalizes bounded pixel values without accepting incompatible units", () => {
@@ -378,7 +378,7 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
<Section title="Layout">
<Section title="Layout" disabledReason={element.capabilities.reasonIfDisabled}>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
@@ -360,11 +360,13 @@ export function Section({
title,
children,
accessory,
disabledReason,
defaultCollapsed = false,
}: {
title: string;
children: ReactNode;
accessory?: ReactNode;
disabledReason?: string;
defaultCollapsed?: boolean;
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
@@ -406,7 +408,19 @@ export function Section({
</button>
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
</div>
{!collapsed && <div className="px-4 pb-3">{children}</div>}
{!collapsed && (
<div className="px-4 pb-3">
{disabledReason && (
<p
data-disabled-reason
className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400"
>
{disabledReason}
</p>
)}
{children}
</div>
)}
</section>
);
}
@@ -145,7 +145,11 @@ export function StyleSections({
return (
<>
{isFlex && (
<Section title="Flex" defaultCollapsed>
<Section
title="Flex"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<div className="space-y-4">
<SegmentedControl
disabled={styleEditingDisabled}
@@ -190,7 +194,11 @@ export function StyleSections({
)}
{hasVisualBackground && (
<Section title="Radius" defaultCollapsed>
<Section
title="Radius"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<BorderRadiusEditor
tl={radiusTL}
tr={radiusTR}
@@ -215,7 +223,11 @@ export function StyleSections({
</Section>
)}
<Section title="Stroke" defaultCollapsed>
<Section
title="Stroke"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<div className="space-y-4">
<div className={RESPONSIVE_GRID}>
<MetricField
@@ -262,7 +274,11 @@ export function StyleSections({
</div>
</Section>
<Section title="Effects" defaultCollapsed>
<Section
title="Effects"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<div className="space-y-4">
<SelectField
label="Shadow"
@@ -315,7 +331,11 @@ export function StyleSections({
</div>
</Section>
<Section title="Clip" defaultCollapsed>
<Section
title="Clip"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<div className="space-y-4">
<div className={RESPONSIVE_GRID}>
<SelectField
@@ -391,7 +411,11 @@ export function StyleSections({
</div>
</Section>
<Section title="Transparency" defaultCollapsed>
<Section
title="Transparency"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
defaultCollapsed
>
<div className="space-y-4">
<SliderControl
value={opacityValue}
@@ -413,7 +437,10 @@ export function StyleSections({
</div>
</Section>
<Section title="Fill">
<Section
title="Fill"
disabledReason={styleEditingDisabled ? element.capabilities.reasonIfDisabled : undefined}
>
<div className="space-y-4">
<SegmentedControl
disabled={styleEditingDisabled}