From df36722623cd91124f20bebbcb9eb05e05217a58 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 22:04:57 -0700 Subject: [PATCH] feat(studio): add flat empty and multi-select states (#6b) --- .../editor/PropertyPanelEmptyState.test.tsx | 74 +++++++ .../editor/PropertyPanelEmptyState.tsx | 180 +++++++++++++++++- .../components/editor/propertyPanelTypes.ts | 3 + 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx new file mode 100644 index 000000000..397fb161c --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +describe("PropertyPanelEmptyState — flat empty", () => { + it("shows the cursor glyph, headline, and the two shortcut rows", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("Nothing selected"); + expect(host.textContent).toContain("Record a gesture"); + expect(host.textContent).toContain("Describe a change to the agent"); + act(() => root.unmount()); + }); +}); + +describe("PropertyPanelEmptyState — flat multi-select", () => { + const elements = [ + { id: "mono-label", selector: ".mono-label", label: "Mono Label", tagName: "div" }, + { id: null, selector: "#s2-chart", label: "S2 Chart", tagName: "div" }, + ] as unknown as DomEditSelection[]; + + it("lists each selected element and wires group/hide-all/clear actions", () => { + const onGroupSelection = vi.fn(); + const onHideAllSelected = vi.fn(); + const onClearSelection = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("2 elements selected"); + expect(host.textContent).toContain("Mono Label"); + expect(host.textContent).toContain("S2 Chart"); + + const group = host.querySelector('[data-flat-multiselect-group="true"]'); + act(() => group?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onGroupSelection).toHaveBeenCalledTimes(1); + + const hideAll = host.querySelector( + '[data-flat-multiselect-hide-all="true"]', + ); + act(() => hideAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onHideAllSelected).toHaveBeenCalledTimes(1); + + const clear = host.querySelector('[data-flat-multiselect-clear="true"]'); + act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onClearSelection).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index 7070a9e36..4a30f867e 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -1,6 +1,184 @@ import { Eye, Layers } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditingTypes"; + +function FlatEmptyState() { + return ( +
+ + + + + +
Nothing selected
+
+ Click any element on the canvas to edit it, or drag to select several. +
+
+ + + + Record a gesture + + + R + + + + + + Describe a change to the agent + + + ⌘K + + +
+
+ ); +} + +function elementKindGlyph(element: DomEditSelection): { glyph: string; className: string } { + if (element.tagName === "video" || element.tagName === "audio" || element.tagName === "img") { + return { glyph: "◆", className: "bg-panel-media/10 text-panel-media" }; + } + if (element.textFields?.length > 0) { + return { glyph: "T", className: "bg-panel-accent/10 text-panel-accent" }; + } + return { glyph: "▦", className: "bg-panel-container/10 text-panel-container" }; +} + +function FlatMultiSelectState({ + multiSelectCount, + multiSelectedElements = [], + onGroupSelection, + onHideAllSelected, + onClearSelection, +}: { + multiSelectCount: number; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; + onClearSelection?: () => void; +}) { + return ( +
+
+ + + +
+
+ {multiSelectCount} elements selected +
+
+ shift-click to add or remove +
+
+ +
+
+ {multiSelectedElements.map((element) => { + const { glyph, className } = elementKindGlyph(element); + return ( + + + {glyph} + + + {element.label} + + + {element.id ? `#${element.id}` : element.selector} + + + ); + })} +
+
+ + +
+ + Select a single element to edit its properties + +
+ ); +} + +export function PropertyPanelEmptyState({ + multiSelectCount, + flat, + multiSelectedElements, + onGroupSelection, + onHideAllSelected, + onClearSelection, +}: { + multiSelectCount: number; + flat?: boolean; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; + onClearSelection?: () => void; +}) { + if (flat) { + return multiSelectCount > 1 ? ( + + ) : ( + + ); + } -export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) { return (
diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index 48dfef8c5..833264e01 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -25,6 +25,9 @@ export interface PropertyPanelProps { assets: string[]; element: DomEditSelection | null; multiSelectCount?: number; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; copiedAgentPrompt: boolean; onClearSelection: () => void; onUngroup?: () => void;