mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): add FlatRow and FlatSegmentedRow flat-inspector primitives
This commit is contained in:
@@ -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(
|
||||||
|
<FlatRow label="Weight" value="400 · Regular" tier="default" onCommit={vi.fn()} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<FlatRow
|
||||||
|
label="Letter spacing"
|
||||||
|
value="3.96px"
|
||||||
|
tier="explicitCustom"
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
onReset={onReset}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const value = host.querySelector('[data-flat-row-value="true"]');
|
||||||
|
expect(value?.className).toContain("text-panel-accent");
|
||||||
|
const reset = host.querySelector<HTMLButtonElement>('[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(
|
||||||
|
<FlatRow label="Size" value="22px" tier="explicitDefault" onCommit={onCommit} />,
|
||||||
|
);
|
||||||
|
const input = host.querySelector<HTMLInputElement>("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(
|
||||||
|
<FlatSegmentedRow
|
||||||
|
label="Align"
|
||||||
|
options={[
|
||||||
|
{ key: "left", node: "L", active: false },
|
||||||
|
{ key: "right", node: "R", active: true },
|
||||||
|
]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="group flex min-h-[30px] items-center justify-between gap-3">
|
||||||
|
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
||||||
|
<span className="flex min-w-0 flex-shrink-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
data-flat-row-value="true"
|
||||||
|
className={`min-w-0 border-b pb-px font-mono text-[11px] ${VALUE_TIER_VALUE_CLASS[tier]} ${
|
||||||
|
tier === "explicitCustom"
|
||||||
|
? "border-transparent group-hover:border-panel-accent/35"
|
||||||
|
: "border-transparent group-hover:border-panel-border-input"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CommitField
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
liveCommit={liveCommit}
|
||||||
|
onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{suffix}
|
||||||
|
{tier === "explicitCustom" && onReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-row-reset="true"
|
||||||
|
title="Remove — fall back to default"
|
||||||
|
onClick={onReset}
|
||||||
|
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{dropdown && (
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="currentColor"
|
||||||
|
className="flex-shrink-0 text-panel-text-5"
|
||||||
|
>
|
||||||
|
<path d="M2 3l3 4 3-4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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 (
|
||||||
|
<div className="flex min-h-[32px] items-center justify-between">
|
||||||
|
<span className="text-[11px] text-panel-text-3">{label}</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<span key={option.key} className="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-segment="true"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(option.key)}
|
||||||
|
className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${
|
||||||
|
option.active
|
||||||
|
? "border-b-2 border-panel-accent text-panel-text-0"
|
||||||
|
: "border-b-2 border-transparent text-panel-text-4 hover:text-panel-text-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.node}
|
||||||
|
</button>
|
||||||
|
{spacerAfterIndex === index && <span className="w-3" aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
|
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
|
||||||
|
|
||||||
function CommitField({
|
export function CommitField({
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
liveCommit,
|
liveCommit,
|
||||||
|
|||||||
Reference in New Issue
Block a user