diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx new file mode 100644 index 000000000..30ad145c9 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; + +(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("FlatRow", () => { + it("renders the default tier with no reset button", () => { + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-text-3"); + expect(host.querySelector('[data-flat-row-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a mint value and a reset button", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[data-flat-row-reset="true"]'); + expect(reset).not.toBeNull(); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("commits edits through the underlying CommitField input", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const input = host.querySelector("input"); + if (!input) throw new Error("expected an input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, "24px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onCommit).toHaveBeenCalledWith("24px"); + act(() => root.unmount()); + }); +}); + +describe("FlatSegmentedRow", () => { + it("underlines the active option in mint and leaves others muted", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const options = host.querySelectorAll('[data-flat-segment="true"]'); + expect(options).toHaveLength(2); + expect((options[0] as HTMLElement).className).toContain("text-panel-text-4"); + expect((options[1] as HTMLElement).className).toContain("border-panel-accent"); + act(() => + (options[0] as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(onChange).toHaveBeenCalledWith("left"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx new file mode 100644 index 000000000..5acd863ff --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -0,0 +1,133 @@ +import { type ReactNode } from "react"; +import { RotateCcw } from "../../icons/SystemIcons"; +import { CommitField } from "./propertyPanelPrimitives"; +import { + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, + type PropertyValueTier, +} from "./propertyPanelValueTier"; + +/* ------------------------------------------------------------------ */ +/* FlatRow — single-column label/value property row */ +/* ------------------------------------------------------------------ */ + +export function FlatRow({ + label, + value, + tier, + disabled, + liveCommit, + suffix, + dropdown, + onCommit, + onReset, +}: { + label: string; + value: string; + tier: PropertyValueTier; + disabled?: boolean; + liveCommit?: boolean; + suffix?: ReactNode; + /** Renders a trailing 10px caret-down, for select-backed rows. */ + dropdown?: boolean; + onCommit: (nextValue: string) => void; + onReset?: () => void; +}) { + return ( +
+ {label} + + + + + {suffix} + {tier === "explicitCustom" && onReset && ( + + )} + {dropdown && ( + + + + )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSegmentedRow — inline glyph runs, no container background */ +/* ------------------------------------------------------------------ */ + +export interface FlatSegmentOption { + key: string; + node: ReactNode; + active: boolean; +} + +export function FlatSegmentedRow({ + label, + options, + disabled, + /** Index (0-based) after which to render a 12px spacer — for combined rows + * like Text's "Case · Style", which pack two independent option groups. */ + spacerAfterIndex, + onChange, +}: { + label: string; + options: FlatSegmentOption[]; + disabled?: boolean; + spacerAfterIndex?: number; + onChange: (nextKey: string) => void; +}) { + return ( +
+ {label} + + {options.map((option, index) => ( + + + {spacerAfterIndex === index && + ))} + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index ae1243560..d8a36eeef 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers"; -function CommitField({ +export function CommitField({ value, disabled, liveCommit,