From 0152667526af025a5be78cd703014344dcb6be87 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 21:02:38 -0700 Subject: [PATCH] feat(studio): add flat identity header for the inspector --- .../editor/PropertyPanelFlatHeader.test.tsx | 79 ++++++++++++++++ .../editor/PropertyPanelFlatHeader.tsx | 89 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx new file mode 100644 index 000000000..2d467db82 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderHeader(overrides: Partial[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const props = { + name: "Mono Label", + meta: ".mono-label · div", + elementKind: "text" as const, + hidden: false, + copied: false, + onCopy: vi.fn(), + onClear: vi.fn(), + showUngroup: false, + ...overrides, + }; + act(() => { + root.render(); + }); + return { host, root, props }; +} + +describe("PropertyPanelFlatHeader", () => { + it("renders name, meta, and the mint text-type icon", () => { + const { host, root } = renderHeader(); + expect(host.textContent).toContain("Mono Label"); + expect(host.textContent).toContain(".mono-label · div"); + const icon = host.querySelector('[data-flat-header-icon="true"]'); + expect(icon?.className).toContain("text-panel-accent"); + act(() => root.unmount()); + }); + + it("colors the media icon cyan and the other icon amber", () => { + const { host: mediaHost, root: mediaRoot } = renderHeader({ elementKind: "media" }); + expect(mediaHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-media", + ); + act(() => mediaRoot.unmount()); + + const { host: otherHost, root: otherRoot } = renderHeader({ elementKind: "other" }); + expect(otherHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-container", + ); + act(() => otherRoot.unmount()); + }); + + it("fires onCopy and onClear from their action buttons", () => { + const { host, root, props } = renderHeader(); + const copy = host.querySelector( + '[aria-label="Copy element info to clipboard"]', + ); + const clear = host.querySelector('[aria-label="Clear selection"]'); + act(() => copy?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(props.onCopy).toHaveBeenCalledTimes(1); + expect(props.onClear).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("only renders Ungroup when showUngroup is true", () => { + const { host: without } = renderHeader({ showUngroup: false }); + expect(without.querySelector('[aria-label="Ungroup"]')).toBeNull(); + + const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() }); + expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx new file mode 100644 index 000000000..d56468eb5 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx @@ -0,0 +1,89 @@ +import { Eye, EyeSlash } from "@phosphor-icons/react"; +import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons"; + +const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const; +const ICON_COLOR_BY_KIND = { + text: "text-panel-accent", + media: "text-panel-media", + other: "text-panel-container", +} as const; + +export function PropertyPanelFlatHeader({ + name, + meta, + elementKind, + hidden, + onToggleHidden, + copied, + onCopy, + onClear, + onUngroup, + showUngroup, +}: { + name: string; + meta: string; + elementKind: "text" | "media" | "other"; + hidden: boolean; + onToggleHidden?: () => void; + copied: boolean; + onCopy: () => void; + onClear: () => void; + onUngroup?: () => void; + showUngroup: boolean; +}) { + const Icon = ICON_BY_KIND[elementKind]; + const visibilityLabel = hidden ? "Show element" : "Hide element"; + + return ( +
+ +
+ {name} + {meta} +
+
+ {showUngroup && ( + + )} + {onToggleHidden && ( + + )} + + +
+
+ ); +}