fix(studio): accept 3-digit hex shorthand in the colour field

The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 18:04:21 +02:00
parent 7f0cadcbb1
commit 86f633f985
2 changed files with 30 additions and 4 deletions
@@ -144,14 +144,31 @@ describe("ColorField hex editing", () => {
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
});
it("does not commit an incomplete pending hex on outside-click", () => {
it("commits a 3-digit hex shorthand on outside-click", () => {
// parseCssColor accepts shorthand, so the gesture resolver has to as well;
// #F00 is ordinary designer input and used to be dropped in silence.
const onCommit = vi.fn();
const input = openHexInput(renderColorField({ onCommit }));
act(() => changeInput(input, "#F00"));
act(clickOutside);
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("rgb(255, 0, 0)");
});
it("does not commit an incomplete pending hex on outside-click", () => {
const onCommit = vi.fn();
const host = renderColorField({ value: "#224466", onCommit });
const input = openHexInput(host);
act(() => changeInput(input, "#12AB3"));
act(clickOutside);
expect(onCommit).not.toHaveBeenCalled();
// The settle also has to put the field back, or the panel re-opens showing
// a value the composition never took.
expect(openHexInput(host).value).toBe("#224466");
});
it("cancels a pending hex edit on Escape and restores the previous value", () => {
@@ -180,16 +197,22 @@ describe("ColorField hex editing", () => {
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
});
it("live-previews only a complete six-digit hex", () => {
it("live-previews only a hex length that parses, 3 or 6 digits", () => {
const onPreview = vi.fn();
const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
act(() => changeInput(input, "#333"));
// 4 and 5 digits are mid-typing, so they must stay silent.
act(() => changeInput(input, "#3333"));
act(() => changeInput(input, "#33333"));
expect(onPreview).not.toHaveBeenCalled();
act(() => changeInput(input, "#333333"));
expect(onPreview).toHaveBeenCalledOnce();
expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");
act(() => changeInput(input, "#333"));
expect(onPreview).toHaveBeenCalledTimes(2);
expect(onPreview).toHaveBeenLastCalledWith("rgb(51, 51, 51)");
});
it("tracks exactly once per completed edit, not once per keystroke", () => {
@@ -192,7 +192,10 @@ export function ColorField({
}, []);
const resolveColorGestureValue = useCallback((nextValue: string) => {
const source = nextValue.startsWith("#") ? "hex" : "picker";
if (source === "hex" && !/^#[0-9a-f]{6}$/i.test(nextValue)) return null;
// Only a COMPLETE hex resolves, so a half-typed one neither previews nor
// commits. Both lengths parseCssColor accepts count as complete: gating on
// 6 alone silently dropped #F00 and friends, which the old onBlur committed.
if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
const nextColor = parseCssColor(nextValue);
if (!nextColor) return null;
return {