mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(studio): let the colour hex field be edited and commit it on outside press
Split hex-draft ownership so the hex input is the sole author of its own text while editing (updateColorDraft no longer stamps a canonical hex back over every keystroke), fixing snap-back on backspace and the silent wrong-colour clobber on non-repeating hex values. Route hex typing through the shared gesture transaction so outside-click and Escape settle/cancel it like the other inspector fields, instead of relying on a private onBlur commit that never fires once the panel unmounts on outside-click.
This commit is contained in:
@@ -1,43 +1,85 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
|
||||||
import { ColorField } from "./propertyPanelColor";
|
import { ColorField } from "./propertyPanelColor";
|
||||||
|
|
||||||
|
const trackStudioEvent = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../utils/studioTelemetry", () => ({
|
||||||
|
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
(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;
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trackStudioEvent.mockReset();
|
||||||
|
__resetDesignInputThrottle();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
for (const root of roots) act(() => root.unmount());
|
||||||
|
roots.length = 0;
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderColorField(onCommit: (value: string) => void): void {
|
function renderColorField({
|
||||||
|
value = "#333333",
|
||||||
|
onPreview,
|
||||||
|
onCommit = vi.fn(),
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onPreview?: (value: string) => void;
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
} = {}): HTMLElement {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
document.body.append(host);
|
document.body.append(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
roots.push(root);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
|
root.render(
|
||||||
|
<ColorField flat label="Color" value={value} onPreview={onPreview} onCommit={onCommit} />,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(input: HTMLInputElement, value: string): void {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
if (!setter) throw new Error("expected native input value setter");
|
||||||
|
setter.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHexInput(host: HTMLElement): HTMLInputElement {
|
||||||
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
||||||
|
if (!trigger) throw new Error("Color trigger was not rendered");
|
||||||
|
act(() => trigger.click());
|
||||||
|
const input = document.querySelector<HTMLInputElement>('input[spellcheck="false"]');
|
||||||
|
if (!input) throw new Error("Hex input was not rendered");
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickOutside(): void {
|
||||||
|
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ColorField flat trigger", () => {
|
describe("ColorField flat trigger", () => {
|
||||||
it("renders label and value inline with a small swatch, no boxed border", () => {
|
it("renders label and value inline with a small swatch, no boxed border", () => {
|
||||||
const host = document.createElement("div");
|
const host = renderColorField({ value: "rgb(255, 176, 32)" });
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
|
||||||
act(() => {
|
|
||||||
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
|
|
||||||
});
|
|
||||||
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
||||||
expect(trigger).not.toBeNull();
|
expect(trigger).not.toBeNull();
|
||||||
expect(trigger?.className).not.toContain("border-neutral-800");
|
expect(trigger?.className).not.toContain("border-neutral-800");
|
||||||
expect(host.textContent).toContain("Color");
|
expect(host.textContent).toContain("Color");
|
||||||
act(() => root.unmount());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists one keyboard slider gesture on keyup", () => {
|
it("persists one keyboard slider gesture on keyup", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
renderColorField(onCommit);
|
renderColorField({ value: "rgb(255, 176, 32)", onCommit });
|
||||||
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
||||||
if (!trigger) throw new Error("Color trigger was not rendered");
|
if (!trigger) throw new Error("Color trigger was not rendered");
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -57,3 +99,107 @@ describe("ColorField flat trigger", () => {
|
|||||||
expect(onCommit).toHaveBeenCalledOnce();
|
expect(onCommit).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ColorField hex editing", () => {
|
||||||
|
it("allows #333333 to be backspaced to #3 without snapping", () => {
|
||||||
|
const input = openHexInput(renderColorField());
|
||||||
|
|
||||||
|
for (const value of ["#33333", "#3333", "#333", "#33", "#3"]) {
|
||||||
|
act(() => changeInput(input, value));
|
||||||
|
expect(input.value).toBe(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not silently change #22CC66 to #2222CC while backspacing", () => {
|
||||||
|
const input = openHexInput(renderColorField({ value: "#22CC66" }));
|
||||||
|
|
||||||
|
for (const value of ["#22CC6", "#22CC", "#22C"]) {
|
||||||
|
act(() => changeInput(input, value));
|
||||||
|
expect(input.value).toBe(value);
|
||||||
|
expect(input.value).not.toBe("#2222CC");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a full replacement after selecting the existing value", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const input = openHexInput(renderColorField({ onCommit }));
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#12AB34"));
|
||||||
|
act(() => input.blur());
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledOnce();
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a complete pending hex on outside-click", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const input = openHexInput(renderColorField({ onCommit }));
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#12AB34"));
|
||||||
|
act(clickOutside);
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledOnce();
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not commit an incomplete pending hex on outside-click", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const input = openHexInput(renderColorField({ onCommit }));
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#12AB3"));
|
||||||
|
act(clickOutside);
|
||||||
|
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending hex edit on Escape and restores the previous value", () => {
|
||||||
|
const onPreview = vi.fn();
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const host = renderColorField({ value: "#224466", onPreview, onCommit });
|
||||||
|
const input = openHexInput(host);
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#12AB34"));
|
||||||
|
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||||
|
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
expect(onPreview).toHaveBeenLastCalledWith("rgb(34, 68, 102)");
|
||||||
|
expect(openHexInput(host).value).toBe("#224466");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still commits a complete hex on Tab-blur", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const input = openHexInput(renderColorField({ onCommit }));
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#12AB34"));
|
||||||
|
act(() => input.blur());
|
||||||
|
|
||||||
|
expect(onCommit).toHaveBeenCalledOnce();
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("live-previews only a complete six-digit hex", () => {
|
||||||
|
const onPreview = vi.fn();
|
||||||
|
const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#333"));
|
||||||
|
expect(onPreview).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => changeInput(input, "#333333"));
|
||||||
|
expect(onPreview).toHaveBeenCalledOnce();
|
||||||
|
expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks exactly once per completed edit, not once per keystroke", () => {
|
||||||
|
const input = openHexInput(renderColorField());
|
||||||
|
|
||||||
|
for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
|
||||||
|
act(() => changeInput(input, value));
|
||||||
|
}
|
||||||
|
act(clickOutside);
|
||||||
|
|
||||||
|
expect(trackStudioEvent).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -184,11 +184,24 @@ export function ColorField({
|
|||||||
const brightnessPercent = Math.round(hsv.value * 100);
|
const brightnessPercent = Math.round(hsv.value * 100);
|
||||||
const alphaPercent = Math.round(draftColor.alpha * 100);
|
const alphaPercent = Math.round(draftColor.alpha * 100);
|
||||||
|
|
||||||
const updateColorDraft = useCallback((nextValue: string) => {
|
const updateColorDraft = useCallback((nextValue: string, source: "hex" | "picker") => {
|
||||||
const nextColor = parseCssColor(nextValue);
|
const nextColor = parseCssColor(nextValue);
|
||||||
if (!nextColor) return;
|
if (!nextColor) return;
|
||||||
setDraftColor(nextColor);
|
setDraftColor(nextColor);
|
||||||
setHexDraft(toHexColor(nextColor).toUpperCase());
|
if (source === "picker") setHexDraft(toHexColor(nextColor).toUpperCase());
|
||||||
|
}, []);
|
||||||
|
const resolveColorGestureValue = useCallback((nextValue: string) => {
|
||||||
|
const source = nextValue.startsWith("#") ? "hex" : "picker";
|
||||||
|
if (source === "hex" && !/^#[0-9a-f]{6}$/i.test(nextValue)) return null;
|
||||||
|
const nextColor = parseCssColor(nextValue);
|
||||||
|
if (!nextColor) return null;
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
value: formatCssColor({
|
||||||
|
...nextColor,
|
||||||
|
alpha: source === "hex" ? draftColorRef.current.alpha : nextColor.alpha,
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
}, []);
|
}, []);
|
||||||
const persistColorValue = useCallback(
|
const persistColorValue = useCallback(
|
||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
@@ -203,12 +216,17 @@ export function ColorField({
|
|||||||
settle: settleColorGesture,
|
settle: settleColorGesture,
|
||||||
cancel: cancelColorGesture,
|
cancel: cancelColorGesture,
|
||||||
} = useInspectorGestureTransaction({
|
} = useInspectorGestureTransaction({
|
||||||
sourceValue: value,
|
sourceValue: formatCssColor(colorFromCss(value)),
|
||||||
onPreview: (nextValue) => {
|
onPreview: (nextValue) => {
|
||||||
updateColorDraft(nextValue);
|
const resolved = resolveColorGestureValue(nextValue);
|
||||||
onPreview?.(nextValue);
|
if (!resolved) return;
|
||||||
|
updateColorDraft(resolved.value, resolved.source);
|
||||||
|
onPreview?.(resolved.value);
|
||||||
|
},
|
||||||
|
onCommit: (nextValue) => {
|
||||||
|
const resolved = resolveColorGestureValue(nextValue);
|
||||||
|
if (resolved) persistColorValue(resolved.value);
|
||||||
},
|
},
|
||||||
onCommit: persistColorValue,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,13 +306,11 @@ export function ColorField({
|
|||||||
commitHsv({ saturation, value: nextValue });
|
commitHsv({ saturation, value: nextValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHexCommit = (nextHex: string) => {
|
const handleHexChange = (nextHex: string) => {
|
||||||
setHexDraft(nextHex);
|
setHexDraft(nextHex);
|
||||||
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
||||||
const parsed = parseCssColor(normalized);
|
beginColorGesture();
|
||||||
if (!parsed) return;
|
previewColorGesture(normalized);
|
||||||
const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
|
|
||||||
updateColorDraft(nextValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const picker = open
|
const picker = open
|
||||||
@@ -413,21 +429,8 @@ export function ColorField({
|
|||||||
<span className={LABEL}>Hex</span>
|
<span className={LABEL}>Hex</span>
|
||||||
<input
|
<input
|
||||||
value={hexDraft}
|
value={hexDraft}
|
||||||
onChange={(event) => handleHexCommit(event.target.value)}
|
onChange={(event) => handleHexChange(event.target.value)}
|
||||||
onBlur={() => {
|
onBlur={settleColorGesture}
|
||||||
const normalized = hexDraft.trim().startsWith("#")
|
|
||||||
? hexDraft.trim()
|
|
||||||
: `#${hexDraft.trim()}`;
|
|
||||||
const parsed = parseCssColor(normalized);
|
|
||||||
if (parsed) {
|
|
||||||
const nextValue = formatCssColor({
|
|
||||||
...parsed,
|
|
||||||
alpha: draftColorRef.current.alpha,
|
|
||||||
});
|
|
||||||
persistColorValue(nextValue);
|
|
||||||
}
|
|
||||||
setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
|
|
||||||
}}
|
|
||||||
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user