mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2135 from heygen-com/studio-flat-10-remove-pinning
refactor(studio): remove section pinning from the flat inspector
This commit is contained in:
@@ -18,10 +18,6 @@ vi.mock("../../contexts/StudioContext", async () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
// usePersistedPinnedGroups persists to localStorage; clear it so a pinned
|
|
||||||
// group from one test can't leak into the next (which would move a group out
|
|
||||||
// of the accordion and break an unrelated open-by-default assertion).
|
|
||||||
window.localStorage.clear();
|
|
||||||
vi.doUnmock("./manualEditingAvailability");
|
vi.doUnmock("./manualEditingAvailability");
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
@@ -813,47 +809,6 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PropertyPanel — pinning", () => {
|
|
||||||
it(
|
|
||||||
"renders a pinned group first, always open, above the PinnedZoneDivider",
|
|
||||||
async () => {
|
|
||||||
const { host, root } = await renderPanel(true);
|
|
||||||
// Pin the Text group via its pin button.
|
|
||||||
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
|
|
||||||
if (!pinButton) throw new Error("expected a pin button on the open Text group");
|
|
||||||
act(() => pinButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
||||||
|
|
||||||
const pinnedRow = host.querySelector('[data-pinned-group="true"]');
|
|
||||||
expect(pinnedRow?.textContent).toContain("Text");
|
|
||||||
expect(pinnedRow?.textContent).toContain("Pinned");
|
|
||||||
|
|
||||||
// The divider must appear after the pinned zone.
|
|
||||||
const container = host.querySelector('[data-flat-panel-body="true"]');
|
|
||||||
const children = Array.from(container?.children ?? []);
|
|
||||||
const pinnedIndex = children.indexOf(pinnedRow as Element);
|
|
||||||
const dividerIndex = children.findIndex((el) => el.textContent?.includes("one open below"));
|
|
||||||
expect(pinnedIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(dividerIndex).toBeGreaterThan(pinnedIndex);
|
|
||||||
act(() => root.unmount());
|
|
||||||
},
|
|
||||||
RENDER_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"unpinning returns the group to its normal accordion stack position",
|
|
||||||
async () => {
|
|
||||||
const { host, root } = await renderPanel(true);
|
|
||||||
const pinButton = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
|
|
||||||
act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
||||||
const unpinButton = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
|
|
||||||
act(() => unpinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
||||||
expect(host.querySelector('[data-pinned-group="true"]')).toBeNull();
|
|
||||||
act(() => root.unmount());
|
|
||||||
},
|
|
||||||
RENDER_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// design_handoff scrollable-open-section: collapsed headers before/after the
|
// design_handoff scrollable-open-section: collapsed headers before/after the
|
||||||
// open group render in normal document flow and never move (no sticky, no
|
// open group render in normal document flow and never move (no sticky, no
|
||||||
// stacking offsets) — only the open group's own body content scrolls, in a
|
// stacking offsets) — only the open group's own body content scrolls, in a
|
||||||
@@ -882,8 +837,7 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
|
|||||||
if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
|
if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
// Filter to just the group entries (drop nulls from any divider, none
|
// Filter to just the group entries (drop any non-group nulls).
|
||||||
// expected here since no groups are pinned).
|
|
||||||
const groupTitles = titles.filter((t): t is string => t !== null);
|
const groupTitles = titles.filter((t): t is string => t !== null);
|
||||||
expect(groupTitles).toHaveLength(6);
|
expect(groupTitles).toHaveLength(6);
|
||||||
expect(groupTitles[0]).toContain("Text");
|
expect(groupTitles[0]).toContain("Text");
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|||||||
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
||||||
// Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and
|
// Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and
|
||||||
// the values the legacy path already computed above (so they aren't derived
|
// the values the legacy path already computed above (so they aren't derived
|
||||||
// twice). PropertyPanelFlat owns the one-open/pin group state.
|
// twice). PropertyPanelFlat owns the one-open group state.
|
||||||
return (
|
return (
|
||||||
<PropertyPanelFlat
|
<PropertyPanelFlat
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
|||||||
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
||||||
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
||||||
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
||||||
import { FlatGroupHeader, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
|
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
|
||||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||||
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
||||||
@@ -17,7 +17,6 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
|||||||
import { formatTextFieldPreview } from "./propertyPanelSections";
|
import { formatTextFieldPreview } from "./propertyPanelSections";
|
||||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||||
import { useColorGradingController } from "./useColorGradingController";
|
import { useColorGradingController } from "./useColorGradingController";
|
||||||
import { usePersistedPinnedGroups } from "../../hooks/usePersistedPinnedGroups";
|
|
||||||
import {
|
import {
|
||||||
FlatColorGradingAccessory,
|
FlatColorGradingAccessory,
|
||||||
FlatColorGradingSection,
|
FlatColorGradingSection,
|
||||||
@@ -52,7 +51,7 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
|
|||||||
*
|
*
|
||||||
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
|
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
|
||||||
* (same one-directional-import precedent as FlatTextSection). Rendered only
|
* (same one-directional-import precedent as FlatTextSection). Rendered only
|
||||||
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
|
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
|
||||||
*
|
*
|
||||||
* The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
|
* The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
|
||||||
*/
|
*/
|
||||||
@@ -252,7 +251,6 @@ export function PropertyPanelFlat({
|
|||||||
|
|
||||||
const isTextEditable = isTextEditableSelection(element);
|
const isTextEditable = isTextEditableSelection(element);
|
||||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||||
const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind);
|
|
||||||
const toggleOpen = (groupId: string) =>
|
const toggleOpen = (groupId: string) =>
|
||||||
setOpenGroupId((current) => (current === groupId ? "" : groupId));
|
setOpenGroupId((current) => (current === groupId ? "" : groupId));
|
||||||
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
||||||
@@ -311,9 +309,8 @@ export function PropertyPanelFlat({
|
|||||||
const showMotionGroup = showMotionTiming || showMotionEffects;
|
const showMotionGroup = showMotionTiming || showMotionEffects;
|
||||||
|
|
||||||
// Ordered group descriptors — one per FlatGroup this panel renders, gated by
|
// Ordered group descriptors — one per FlatGroup this panel renders, gated by
|
||||||
// the same conditions the inline JSX used. Partitioned into pinned/unpinned
|
// the same conditions the inline JSX used. Split below into before-open/
|
||||||
// below so pinned groups render first (always open, no accordion) above the
|
// open/after-open regions for the one-open accordion.
|
||||||
// PinnedZoneDivider, with the rest in the one-open accordion beneath it.
|
|
||||||
const groups: FlatGroupDescriptor[] = [];
|
const groups: FlatGroupDescriptor[] = [];
|
||||||
if (isTextEditable) {
|
if (isTextEditable) {
|
||||||
groups.push({
|
groups.push({
|
||||||
@@ -453,9 +450,6 @@ export function PropertyPanelFlat({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id));
|
|
||||||
const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id));
|
|
||||||
|
|
||||||
// Fixed-headers + scrollable-open-section layout (design_handoff
|
// Fixed-headers + scrollable-open-section layout (design_handoff
|
||||||
// scrollable-open-section, replaces the prior sticky-stacking mechanism):
|
// scrollable-open-section, replaces the prior sticky-stacking mechanism):
|
||||||
// collapsed headers before/after the open group render in normal document
|
// collapsed headers before/after the open group render in normal document
|
||||||
@@ -463,10 +457,10 @@ export function PropertyPanelFlat({
|
|||||||
// a dedicated region between the two fixed header stacks. When no group is
|
// a dedicated region between the two fixed header stacks. When no group is
|
||||||
// open, every group is just a collapsed header — there's no scrollable
|
// open, every group is just a collapsed header — there's no scrollable
|
||||||
// middle region at all, since nothing is expanded.
|
// middle region at all, since nothing is expanded.
|
||||||
const openIndex = unpinned.findIndex((g) => g.id === openGroupId);
|
const openIndex = groups.findIndex((g) => g.id === openGroupId);
|
||||||
const beforeOpen = openIndex === -1 ? unpinned : unpinned.slice(0, openIndex);
|
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
|
||||||
const openGroup = openIndex === -1 ? null : unpinned[openIndex];
|
const openGroup = openIndex === -1 ? null : groups[openIndex];
|
||||||
const afterOpen = openIndex === -1 ? [] : unpinned.slice(openIndex + 1);
|
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||||
@@ -487,25 +481,12 @@ export function PropertyPanelFlat({
|
|||||||
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
||||||
/>
|
/>
|
||||||
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{pinned.map((g) => (
|
|
||||||
<PinnedGroupRow
|
|
||||||
key={g.id}
|
|
||||||
title={g.title}
|
|
||||||
accessory={g.accessory}
|
|
||||||
onUnpin={() => togglePin(g.id)}
|
|
||||||
>
|
|
||||||
{g.content}
|
|
||||||
</PinnedGroupRow>
|
|
||||||
))}
|
|
||||||
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
|
|
||||||
{beforeOpen.map((g) => (
|
{beforeOpen.map((g) => (
|
||||||
<FlatGroupHeader
|
<FlatGroupHeader
|
||||||
key={g.id}
|
key={g.id}
|
||||||
title={g.title}
|
title={g.title}
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={() => toggleOpen(g.id)}
|
onToggleOpen={() => toggleOpen(g.id)}
|
||||||
onTogglePin={() => togglePin(g.id)}
|
|
||||||
summary={g.summary}
|
summary={g.summary}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -514,9 +495,7 @@ export function PropertyPanelFlat({
|
|||||||
<FlatGroupHeader
|
<FlatGroupHeader
|
||||||
title={openGroup.title}
|
title={openGroup.title}
|
||||||
isOpen
|
isOpen
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={() => toggleOpen(openGroup.id)}
|
onToggleOpen={() => toggleOpen(openGroup.id)}
|
||||||
onTogglePin={() => togglePin(openGroup.id)}
|
|
||||||
accessory={openGroup.accessory}
|
accessory={openGroup.accessory}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
|
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
|
||||||
@@ -529,9 +508,7 @@ export function PropertyPanelFlat({
|
|||||||
key={g.id}
|
key={g.id}
|
||||||
title={g.title}
|
title={g.title}
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={() => toggleOpen(g.id)}
|
onToggleOpen={() => toggleOpen(g.id)}
|
||||||
onTogglePin={() => togglePin(g.id)}
|
|
||||||
summary={g.summary}
|
summary={g.summary}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -69,10 +69,9 @@ describe("PropertyPanelFlatFooter", () => {
|
|||||||
// rounding gap) — is gone now that nothing above the footer is
|
// rounding gap) — is gone now that nothing above the footer is
|
||||||
// `position: sticky`. Live browser verification (p11 report) confirmed the
|
// `position: sticky`. Live browser verification (p11 report) confirmed the
|
||||||
// boundary renders as a single clean hairline without it: whatever
|
// boundary renders as a single clean hairline without it: whatever
|
||||||
// immediately precedes the footer (a collapsed FlatGroupHeader, the open
|
// immediately precedes the footer (a collapsed FlatGroupHeader, or the open
|
||||||
// group's scrollable body wrapper, or a PinnedGroupRow) already draws its
|
// group's scrollable body wrapper) already draws its own border-b in normal
|
||||||
// own border-b in normal document flow, so the footer needs no border or
|
// document flow, so the footer needs no border or seal of its own.
|
||||||
// seal of its own.
|
|
||||||
it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => {
|
it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => {
|
||||||
const { host, root } = renderFooter({ onAskAgent: vi.fn() });
|
const { host, root } = renderFooter({ onAskAgent: vi.fn() });
|
||||||
const footerRoot = host.firstElementChild as HTMLElement;
|
const footerRoot = host.firstElementChild as HTMLElement;
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function PropertyPanelFlatFooter({
|
|||||||
return (
|
return (
|
||||||
// No border-t here: every possible element immediately above this footer
|
// No border-t here: every possible element immediately above this footer
|
||||||
// in the new fixed-headers + scrollable-open-section layout (a collapsed
|
// in the new fixed-headers + scrollable-open-section layout (a collapsed
|
||||||
// FlatGroupHeader, the open group's scrollable body wrapper, or a
|
// FlatGroupHeader, or the open group's scrollable body wrapper) already
|
||||||
// PinnedGroupRow) already draws its own border-b in normal document flow
|
// draws its own border-b in normal document flow — nothing here is
|
||||||
// — nothing here is `position: sticky` anymore, so there's no rounding
|
// `position: sticky` anymore, so there's no rounding seam to seal (see
|
||||||
// seam to seal (see p11-scrollable-open-section-report.md).
|
// p11-scrollable-open-section-report.md).
|
||||||
<div className="flex items-center justify-between bg-panel-bg px-4 py-[11px]">
|
<div className="flex items-center justify-between bg-panel-bg px-4 py-[11px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
FlatSelectRow,
|
FlatSelectRow,
|
||||||
FlatSlider,
|
FlatSlider,
|
||||||
FlatToggle,
|
FlatToggle,
|
||||||
PinnedGroupRow,
|
|
||||||
PinnedZoneDivider,
|
|
||||||
} from "./propertyPanelFlatPrimitives";
|
} from "./propertyPanelFlatPrimitives";
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -110,22 +108,12 @@ describe("FlatSegmentedRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FlatGroupHeader", () => {
|
describe("FlatGroupHeader", () => {
|
||||||
it("renders the open header (name + pin + caret), with no sticky-related props required", () => {
|
it("renders the open header (name + caret), with no sticky-related props required", () => {
|
||||||
const onToggleOpen = vi.fn();
|
const onToggleOpen = vi.fn();
|
||||||
const onTogglePin = vi.fn();
|
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
<FlatGroupHeader
|
<FlatGroupHeader title="Text" isOpen onToggleOpen={onToggleOpen} />,
|
||||||
title="Text"
|
|
||||||
isOpen
|
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={onToggleOpen}
|
|
||||||
onTogglePin={onTogglePin}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(host.textContent).toContain("Text");
|
expect(host.textContent).toContain("Text");
|
||||||
const pin = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
|
|
||||||
act(() => pin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
||||||
expect(onTogglePin).toHaveBeenCalledTimes(1);
|
|
||||||
const collapse = host.querySelector<HTMLButtonElement>('button[title="Collapse"]');
|
const collapse = host.querySelector<HTMLButtonElement>('button[title="Collapse"]');
|
||||||
act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||||
@@ -138,9 +126,7 @@ describe("FlatGroupHeader", () => {
|
|||||||
<FlatGroupHeader
|
<FlatGroupHeader
|
||||||
title="Style"
|
title="Style"
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={onToggleOpen}
|
onToggleOpen={onToggleOpen}
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
summary="fill none · 100%"
|
summary="fill none · 100%"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -154,13 +140,7 @@ describe("FlatGroupHeader", () => {
|
|||||||
|
|
||||||
it("renders no inline position styling in either state (collapsed headers never move)", () => {
|
it("renders no inline position styling in either state (collapsed headers never move)", () => {
|
||||||
const { host: collapsedHost, root: collapsedRoot } = renderInto(
|
const { host: collapsedHost, root: collapsedRoot } = renderInto(
|
||||||
<FlatGroupHeader
|
<FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
|
||||||
title="Layout"
|
|
||||||
isOpen={false}
|
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
const row = collapsedHost.querySelector<HTMLButtonElement>(
|
const row = collapsedHost.querySelector<HTMLButtonElement>(
|
||||||
'[data-flat-group-collapsed="true"]',
|
'[data-flat-group-collapsed="true"]',
|
||||||
@@ -169,13 +149,7 @@ describe("FlatGroupHeader", () => {
|
|||||||
act(() => collapsedRoot.unmount());
|
act(() => collapsedRoot.unmount());
|
||||||
|
|
||||||
const { host: openHost, root: openRoot } = renderInto(
|
const { host: openHost, root: openRoot } = renderInto(
|
||||||
<FlatGroupHeader
|
<FlatGroupHeader title="Motion" isOpen onToggleOpen={vi.fn()} />,
|
||||||
title="Motion"
|
|
||||||
isOpen
|
|
||||||
isPinned={false}
|
|
||||||
onToggleOpen={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(openHost.textContent).toContain("Motion");
|
expect(openHost.textContent).toContain("Motion");
|
||||||
expect(openHost.querySelector("[style]")).toBeNull();
|
expect(openHost.querySelector("[style]")).toBeNull();
|
||||||
@@ -183,14 +157,6 @@ describe("FlatGroupHeader", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PinnedZoneDivider", () => {
|
|
||||||
it("renders the 'one open below' label", () => {
|
|
||||||
const { host, root } = renderInto(<PinnedZoneDivider />);
|
|
||||||
expect(host.textContent).toContain("one open below");
|
|
||||||
act(() => root.unmount());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("FlatSlider", () => {
|
describe("FlatSlider", () => {
|
||||||
it("renders the default tier with a dim knob at the correct position", () => {
|
it("renders the default tier with a dim knob at the correct position", () => {
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
@@ -486,21 +452,3 @@ describe("FlatToggle", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PinnedGroupRow", () => {
|
|
||||||
it("renders a 'Pinned' badge, filled pin icon, and always shows children", () => {
|
|
||||||
const onUnpin = vi.fn();
|
|
||||||
const { host, root } = renderInto(
|
|
||||||
<PinnedGroupRow title="Motion" onUnpin={onUnpin}>
|
|
||||||
<div data-testid="body">body</div>
|
|
||||||
</PinnedGroupRow>,
|
|
||||||
);
|
|
||||||
expect(host.textContent).toContain("Pinned");
|
|
||||||
expect(host.textContent).toContain("Motion");
|
|
||||||
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
|
|
||||||
const unpin = host.querySelector<HTMLButtonElement>('[data-pinned-group-unpin="true"]');
|
|
||||||
act(() => unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
||||||
expect(onUnpin).toHaveBeenCalledTimes(1);
|
|
||||||
act(() => root.unmount());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function FlatSegmentedRow({
|
|||||||
/* FlatGroupHeader — one-open-at-a-time accordion group header */
|
/* FlatGroupHeader — one-open-at-a-time accordion group header */
|
||||||
/* (fixed-headers + scrollable-open-section layout, design_handoff */
|
/* (fixed-headers + scrollable-open-section layout, design_handoff */
|
||||||
/* scrollable-open-section): renders ONLY the header bar — collapsed */
|
/* scrollable-open-section): renders ONLY the header bar — collapsed */
|
||||||
/* button, or open-state title bar with pin/collapse controls. Never */
|
/* button, or open-state title bar with the collapse control. Never */
|
||||||
/* positioned (no sticky, no stacking offsets) — it always sits in */
|
/* positioned (no sticky, no stacking offsets) — it always sits in */
|
||||||
/* normal document flow. The open group's body content is rendered by */
|
/* normal document flow. The open group's body content is rendered by */
|
||||||
/* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */
|
/* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */
|
||||||
@@ -146,17 +146,13 @@ export function FlatSegmentedRow({
|
|||||||
export function FlatGroupHeader({
|
export function FlatGroupHeader({
|
||||||
title,
|
title,
|
||||||
isOpen,
|
isOpen,
|
||||||
isPinned,
|
|
||||||
onToggleOpen,
|
onToggleOpen,
|
||||||
onTogglePin,
|
|
||||||
accessory,
|
accessory,
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isPinned: boolean;
|
|
||||||
onToggleOpen: () => void;
|
onToggleOpen: () => void;
|
||||||
onTogglePin: () => void;
|
|
||||||
accessory?: ReactNode;
|
accessory?: ReactNode;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -194,17 +190,6 @@ export function FlatGroupHeader({
|
|||||||
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
||||||
<span className="flex items-center gap-2.5 text-panel-text-5">
|
<span className="flex items-center gap-2.5 text-panel-text-5">
|
||||||
{accessory}
|
{accessory}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-flat-group-pin="true"
|
|
||||||
title={isPinned ? "Unpin" : "Pin"}
|
|
||||||
onClick={onTogglePin}
|
|
||||||
className={isPinned ? "text-panel-accent" : "text-panel-text-5 hover:text-panel-text-3"}
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
||||||
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onToggleOpen} title="Collapse" className="text-panel-text-3">
|
<button type="button" onClick={onToggleOpen} title="Collapse" className="text-panel-text-3">
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||||
<path d="M2 4l4 4 4-4z" />
|
<path d="M2 4l4 4 4-4z" />
|
||||||
@@ -215,22 +200,6 @@ export function FlatGroupHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* PinnedZoneDivider */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function PinnedZoneDivider() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2">
|
|
||||||
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
|
|
||||||
<span className="text-[8px] font-semibold uppercase tracking-[0.14em] text-panel-text-5">
|
|
||||||
one open below
|
|
||||||
</span>
|
|
||||||
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* FlatSlider — full-width label/track/value row */
|
/* FlatSlider — full-width label/track/value row */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -446,47 +415,3 @@ export function FlatToggle({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* PinnedGroupRow — always-open pinned group (design_handoff #8a) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function PinnedGroupRow({
|
|
||||||
title,
|
|
||||||
accessory,
|
|
||||||
onUnpin,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
accessory?: ReactNode;
|
|
||||||
onUnpin: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-panel-hairline px-4 py-3" data-pinned-group="true">
|
|
||||||
<div className="mb-2.5 flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-panel-accent">
|
|
||||||
Pinned
|
|
||||||
</span>
|
|
||||||
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-2.5 text-panel-text-5">
|
|
||||||
{accessory}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-pinned-group-unpin="true"
|
|
||||||
title="Unpin — returns to the stack"
|
|
||||||
onClick={onUnpin}
|
|
||||||
className="text-panel-accent"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
||||||
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { usePersistedPinnedGroups } from "./usePersistedPinnedGroups";
|
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.body.innerHTML = "";
|
|
||||||
window.localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
function Harness({
|
|
||||||
elementKind,
|
|
||||||
onReady,
|
|
||||||
}: {
|
|
||||||
elementKind: string;
|
|
||||||
onReady: (api: ReturnType<typeof usePersistedPinnedGroups>) => void;
|
|
||||||
}) {
|
|
||||||
const api = usePersistedPinnedGroups(elementKind);
|
|
||||||
onReady(api);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount(elementKind: string) {
|
|
||||||
const host = document.createElement("div");
|
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
|
||||||
let api!: ReturnType<typeof usePersistedPinnedGroups>;
|
|
||||||
act(() => {
|
|
||||||
root.render(React.createElement(Harness, { elementKind, onReady: (a) => (api = a) }));
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
root,
|
|
||||||
get api() {
|
|
||||||
return api;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("usePersistedPinnedGroups", () => {
|
|
||||||
it("starts empty, toggling a pin adds it, toggling again removes it", () => {
|
|
||||||
const m = mount("text");
|
|
||||||
expect(m.api.pinnedGroupIds).toEqual([]);
|
|
||||||
act(() => m.api.togglePin("motion"));
|
|
||||||
expect(m.api.pinnedGroupIds).toEqual(["motion"]);
|
|
||||||
act(() => m.api.togglePin("motion"));
|
|
||||||
expect(m.api.pinnedGroupIds).toEqual([]);
|
|
||||||
act(() => m.root.unmount());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists across remounts, scoped per element kind", () => {
|
|
||||||
const first = mount("text");
|
|
||||||
act(() => first.api.togglePin("motion"));
|
|
||||||
act(() => first.root.unmount());
|
|
||||||
|
|
||||||
const secondSameKind = mount("text");
|
|
||||||
expect(secondSameKind.api.pinnedGroupIds).toEqual(["motion"]);
|
|
||||||
act(() => secondSameKind.root.unmount());
|
|
||||||
|
|
||||||
const thirdOtherKind = mount("media");
|
|
||||||
expect(thirdOtherKind.api.pinnedGroupIds).toEqual([]);
|
|
||||||
act(() => thirdOtherKind.root.unmount());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
||||||
|
|
||||||
export function usePersistedPinnedGroups(elementKind: string) {
|
|
||||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>(
|
|
||||||
() => readStudioUiPreferences().pinnedGroupsByElementType?.[elementKind] ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const togglePin = useCallback(
|
|
||||||
(groupId: string) => {
|
|
||||||
setPinnedGroupIds((current) => {
|
|
||||||
const next = current.includes(groupId)
|
|
||||||
? current.filter((id) => id !== groupId)
|
|
||||||
: [...current, groupId];
|
|
||||||
const existing = readStudioUiPreferences().pinnedGroupsByElementType ?? {};
|
|
||||||
writeStudioUiPreferences({
|
|
||||||
pinnedGroupsByElementType: { ...existing, [elementKind]: next },
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[elementKind],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { pinnedGroupIds, togglePin };
|
|
||||||
}
|
|
||||||
@@ -88,48 +88,3 @@ describe("timeline zoom pin persistence", () => {
|
|||||||
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeStorage(): Storage {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
getItem: (k) => map.get(k) ?? null,
|
|
||||||
setItem: (k, v) => void map.set(k, v),
|
|
||||||
removeItem: (k) => void map.delete(k),
|
|
||||||
clear: () => map.clear(),
|
|
||||||
key: () => null,
|
|
||||||
get length() {
|
|
||||||
return map.size;
|
|
||||||
},
|
|
||||||
} as Storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pinnedGroupsByElementType", () => {
|
|
||||||
it("round-trips a per-element-type pin map", () => {
|
|
||||||
const storage = fakeStorage();
|
|
||||||
writeStudioUiPreferences(
|
|
||||||
{ pinnedGroupsByElementType: { text: ["motion"], media: ["grade"] } },
|
|
||||||
storage,
|
|
||||||
);
|
|
||||||
const read = readStudioUiPreferences(storage);
|
|
||||||
expect(read.pinnedGroupsByElementType).toEqual({ text: ["motion"], media: ["grade"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a malformed pinnedGroupsByElementType (non-object, or non-string-array values)", () => {
|
|
||||||
const storage = fakeStorage();
|
|
||||||
storage.setItem(
|
|
||||||
"hf-studio-ui-preferences",
|
|
||||||
JSON.stringify({ pinnedGroupsByElementType: { text: "not-an-array", media: [1, 2] } }),
|
|
||||||
);
|
|
||||||
const read = readStudioUiPreferences(storage);
|
|
||||||
expect(read.pinnedGroupsByElementType).toEqual({ media: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges a pin-map patch without clobbering other preferences", () => {
|
|
||||||
const storage = fakeStorage();
|
|
||||||
writeStudioUiPreferences({ audioMuted: true }, storage);
|
|
||||||
writeStudioUiPreferences({ pinnedGroupsByElementType: { text: ["style"] } }, storage);
|
|
||||||
const read = readStudioUiPreferences(storage);
|
|
||||||
expect(read.audioMuted).toBe(true);
|
|
||||||
expect(read.pinnedGroupsByElementType).toEqual({ text: ["style"] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export interface StudioUiPreferences {
|
|||||||
timelineZoomMode?: "fit" | "manual";
|
timelineZoomMode?: "fit" | "manual";
|
||||||
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
||||||
timelineManualZoomPercent?: number;
|
timelineManualZoomPercent?: number;
|
||||||
pinnedGroupsByElementType?: Record<string, string[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
||||||
@@ -116,15 +115,6 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|||||||
) {
|
) {
|
||||||
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
||||||
}
|
}
|
||||||
if (isRecord(parsed.pinnedGroupsByElementType)) {
|
|
||||||
const map: Record<string, string[]> = {};
|
|
||||||
for (const [kind, ids] of Object.entries(parsed.pinnedGroupsByElementType)) {
|
|
||||||
if (Array.isArray(ids)) {
|
|
||||||
map[kind] = ids.filter((id): id is string => typeof id === "string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preferences.pinnedGroupsByElementType = map;
|
|
||||||
}
|
|
||||||
return preferences;
|
return preferences;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
Reference in New Issue
Block a user