mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add flat empty and multi-select states (#6b)
This commit is contained in:
@@ -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(<PropertyPanelEmptyState flat multiSelectCount={0} />);
|
||||||
|
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(
|
||||||
|
<PropertyPanelEmptyState
|
||||||
|
flat
|
||||||
|
multiSelectCount={2}
|
||||||
|
multiSelectedElements={elements}
|
||||||
|
onGroupSelection={onGroupSelection}
|
||||||
|
onHideAllSelected={onHideAllSelected}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("2 elements selected");
|
||||||
|
expect(host.textContent).toContain("Mono Label");
|
||||||
|
expect(host.textContent).toContain("S2 Chart");
|
||||||
|
|
||||||
|
const group = host.querySelector<HTMLButtonElement>('[data-flat-multiselect-group="true"]');
|
||||||
|
act(() => group?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onGroupSelection).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const hideAll = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-flat-multiselect-hide-all="true"]',
|
||||||
|
);
|
||||||
|
act(() => hideAll?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onHideAllSelected).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const clear = host.querySelector<HTMLButtonElement>('[data-flat-multiselect-clear="true"]');
|
||||||
|
act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onClearSelection).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,184 @@
|
|||||||
import { Eye, Layers } from "../../icons/SystemIcons";
|
import { Eye, Layers } from "../../icons/SystemIcons";
|
||||||
|
import type { DomEditSelection } from "./domEditingTypes";
|
||||||
|
|
||||||
|
function FlatEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2.5 px-8 py-10 text-center">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-xl border border-panel-border-input bg-panel-input text-panel-text-3">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
>
|
||||||
|
<path d="M4 3l6 14 2-6 6-2z" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="text-[13px] font-semibold text-panel-text-0">Nothing selected</div>
|
||||||
|
<div className="max-w-[250px] text-[11px] leading-[1.5] text-panel-text-3">
|
||||||
|
Click any element on the canvas to edit it, or drag to select several.
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex w-full flex-col gap-1.5">
|
||||||
|
<span className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-bg px-3 py-2">
|
||||||
|
<span className="flex items-center gap-2 text-[11px] text-panel-text-2">
|
||||||
|
<span className="text-panel-danger">●</span>
|
||||||
|
Record a gesture
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
|
||||||
|
R
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-bg px-3 py-2">
|
||||||
|
<span className="flex items-center gap-2 text-[11px] text-panel-text-2">
|
||||||
|
<span className="text-panel-accent">✦</span>
|
||||||
|
Describe a change to the agent
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
|
||||||
|
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-panel-accent/10 text-panel-accent">
|
||||||
|
<Layers size={16} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-semibold text-panel-text-0">
|
||||||
|
{multiSelectCount} elements selected
|
||||||
|
</div>
|
||||||
|
<div className="mt-px font-mono text-[10px] text-panel-text-3">
|
||||||
|
shift-click to add or remove
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-multiselect-clear="true"
|
||||||
|
aria-label="Clear selection"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center text-panel-text-3"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M3 3l10 10M13 3L3 13" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{multiSelectedElements.map((element) => {
|
||||||
|
const { glyph, className } = elementKindGlyph(element);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={element.id ?? element.selector}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded text-[9px] font-bold ${className}`}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
||||||
|
{element.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
||||||
|
{element.id ? `#${element.id}` : element.selector}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-multiselect-group="true"
|
||||||
|
onClick={onGroupSelection}
|
||||||
|
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
|
||||||
|
>
|
||||||
|
<Layers size={13} />
|
||||||
|
Group selection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-multiselect-hide-all="true"
|
||||||
|
onClick={onHideAllSelected}
|
||||||
|
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
|
||||||
|
>
|
||||||
|
<Eye size={13} />
|
||||||
|
Hide all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-center text-[10px] text-panel-text-5">
|
||||||
|
Select a single element to edit its properties
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<FlatMultiSelectState
|
||||||
|
multiSelectCount={multiSelectCount}
|
||||||
|
multiSelectedElements={multiSelectedElements}
|
||||||
|
onGroupSelection={onGroupSelection}
|
||||||
|
onHideAllSelected={onHideAllSelected}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlatEmptyState />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-neutral-900">
|
<div className="flex h-full flex-col bg-neutral-900">
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface PropertyPanelProps {
|
|||||||
assets: string[];
|
assets: string[];
|
||||||
element: DomEditSelection | null;
|
element: DomEditSelection | null;
|
||||||
multiSelectCount?: number;
|
multiSelectCount?: number;
|
||||||
|
multiSelectedElements?: DomEditSelection[];
|
||||||
|
onGroupSelection?: () => void;
|
||||||
|
onHideAllSelected?: () => void;
|
||||||
copiedAgentPrompt: boolean;
|
copiedAgentPrompt: boolean;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onUngroup?: () => void;
|
onUngroup?: () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user