diff --git a/packages/studio/src/components/editor/propertyPanelColor.test.tsx b/packages/studio/src/components/editor/propertyPanelColor.test.tsx
index 43dcb43e3..82dd74613 100644
--- a/packages/studio/src/components/editor/propertyPanelColor.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelColor.test.tsx
@@ -1,43 +1,85 @@
// @vitest-environment happy-dom
import React, { act } from "react";
-import { createRoot } from "react-dom/client";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
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;
+const roots: Root[] = [];
+
+beforeEach(() => {
+ trackStudioEvent.mockReset();
+ __resetDesignInputThrottle();
+});
+
afterEach(() => {
+ for (const root of roots) act(() => root.unmount());
+ roots.length = 0;
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");
document.body.append(host);
const root = createRoot(host);
+ roots.push(root);
act(() => {
- root.render();
+ root.render(
+ ,
+ );
});
+ 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('[data-flat-color-trigger="true"]');
+ if (!trigger) throw new Error("Color trigger was not rendered");
+ act(() => trigger.click());
+ const input = document.querySelector('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", () => {
it("renders label and value inline with a small swatch, no boxed border", () => {
- const host = document.createElement("div");
- document.body.append(host);
- const root = createRoot(host);
- act(() => {
- root.render();
- });
+ const host = renderColorField({ value: "rgb(255, 176, 32)" });
const trigger = host.querySelector('[data-flat-color-trigger="true"]');
expect(trigger).not.toBeNull();
expect(trigger?.className).not.toContain("border-neutral-800");
expect(host.textContent).toContain("Color");
- act(() => root.unmount());
});
it("persists one keyboard slider gesture on keyup", () => {
const onCommit = vi.fn();
- renderColorField(onCommit);
+ renderColorField({ value: "rgb(255, 176, 32)", onCommit });
const trigger = document.querySelector('[data-flat-color-trigger="true"]');
if (!trigger) throw new Error("Color trigger was not rendered");
act(() => {
@@ -57,3 +99,130 @@ describe("ColorField flat trigger", () => {
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("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", () => {
+ 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 hex length that parses, 3 or 6 digits", () => {
+ const onPreview = vi.fn();
+ const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
+
+ // 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", () => {
+ const input = openHexInput(renderColorField());
+
+ for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
+ act(() => changeInput(input, value));
+ }
+ act(clickOutside);
+
+ expect(trackStudioEvent).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx
index 93471ed04..1443ca6a8 100644
--- a/packages/studio/src/components/editor/propertyPanelColor.tsx
+++ b/packages/studio/src/components/editor/propertyPanelColor.tsx
@@ -184,11 +184,27 @@ export function ColorField({
const brightnessPercent = Math.round(hsv.value * 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);
if (!nextColor) return;
setDraftColor(nextColor);
- setHexDraft(toHexColor(nextColor).toUpperCase());
+ if (source === "picker") setHexDraft(toHexColor(nextColor).toUpperCase());
+ }, []);
+ const resolveColorGestureValue = useCallback((nextValue: string) => {
+ const source = nextValue.startsWith("#") ? "hex" : "picker";
+ // 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 {
+ source,
+ value: formatCssColor({
+ ...nextColor,
+ alpha: source === "hex" ? draftColorRef.current.alpha : nextColor.alpha,
+ }),
+ } as const;
}, []);
const persistColorValue = useCallback(
(nextValue: string) => {
@@ -203,12 +219,17 @@ export function ColorField({
settle: settleColorGesture,
cancel: cancelColorGesture,
} = useInspectorGestureTransaction({
- sourceValue: value,
+ sourceValue: formatCssColor(colorFromCss(value)),
onPreview: (nextValue) => {
- updateColorDraft(nextValue);
- onPreview?.(nextValue);
+ const resolved = resolveColorGestureValue(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(() => {
@@ -288,13 +309,11 @@ export function ColorField({
commitHsv({ saturation, value: nextValue });
};
- const handleHexCommit = (nextHex: string) => {
+ const handleHexChange = (nextHex: string) => {
setHexDraft(nextHex);
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
- const parsed = parseCssColor(normalized);
- if (!parsed) return;
- const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
- updateColorDraft(nextValue);
+ beginColorGesture();
+ previewColorGesture(normalized);
};
const picker = open
@@ -413,21 +432,8 @@ export function ColorField({
Hex
handleHexCommit(event.target.value)}
- onBlur={() => {
- 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());
- }}
+ onChange={(event) => handleHexChange(event.target.value)}
+ onBlur={settleColorGesture}
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
spellCheck={false}
/>
diff --git a/packages/studio/src/player/components/ShortcutsPanel.test.tsx b/packages/studio/src/player/components/ShortcutsPanel.test.tsx
new file mode 100644
index 000000000..31d5b0f9f
--- /dev/null
+++ b/packages/studio/src/player/components/ShortcutsPanel.test.tsx
@@ -0,0 +1,158 @@
+// @vitest-environment happy-dom
+
+import React, { act, Profiler } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ShortcutsPanel } from "./ShortcutsPanel";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) act(() => root.unmount());
+ document.body.innerHTML = "";
+});
+
+function renderPanel(onRender = vi.fn()) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ roots.push(root);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ const trigger = host.querySelector(
+ 'button[aria-label="Shortcuts and tools"]',
+ )!;
+ return { host, trigger, onRender };
+}
+
+function pressAndClick(target: HTMLElement): void {
+ target.dispatchEvent(
+ new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
+ );
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
+}
+
+function openPanel(trigger: HTMLButtonElement): void {
+ act(() => pressAndClick(trigger));
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+}
+
+describe("ShortcutsPanel", () => {
+ it.each(["page", "button", "panel"] as const)(
+ "closes with Escape from the %s-focused position",
+ (focusPosition) => {
+ const { host, trigger } = renderPanel();
+ openPanel(trigger);
+
+ let eventTarget: Document | HTMLElement;
+ if (focusPosition === "page") {
+ document.body.tabIndex = -1;
+ document.body.focus();
+ eventTarget = document;
+ expect(document.activeElement).toBe(document.body);
+ } else if (focusPosition === "button") {
+ trigger.focus();
+ eventTarget = trigger;
+ expect(document.activeElement).toBe(trigger);
+ } else {
+ const panelInput = host.querySelector('[aria-label="Jump to frame"]')!;
+ panelInput.focus();
+ eventTarget = panelInput;
+ expect(document.activeElement).toBe(panelInput);
+ }
+
+ act(() => {
+ eventTarget.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
+ },
+ );
+
+ it("closes on capture-phase pointerdown even when its default is prevented", () => {
+ const preventDefault = (event: Event) => event.preventDefault();
+ document.addEventListener("pointerdown", preventDefault, true);
+ const { host, trigger } = renderPanel();
+ openPanel(trigger);
+ const outside = document.createElement("div");
+ document.body.append(outside);
+ const pointerDown = new PointerEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ });
+
+ act(() => outside.dispatchEvent(pointerDown));
+
+ document.removeEventListener("pointerdown", preventDefault, true);
+ expect(pointerDown.defaultPrevented).toBe(true);
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
+ });
+
+ it("does not close on a click inside the panel", () => {
+ const { host, trigger } = renderPanel();
+ openPanel(trigger);
+ const panelInput = host.querySelector('[aria-label="Jump to frame"]')!;
+
+ act(() => pressAndClick(panelInput));
+
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ expect(host.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
+ });
+
+ it("toggles once per trigger click", () => {
+ const { trigger } = renderPanel();
+
+ act(() => pressAndClick(trigger));
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+
+ act(() => pressAndClick(trigger));
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("does not re-render when Escape is pressed while closed", () => {
+ const { trigger, onRender } = renderPanel();
+ expect(onRender).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(onRender).toHaveBeenCalledTimes(1);
+ });
+
+ it("connects the trigger to the dialog with aria-controls", () => {
+ const { host, trigger } = renderPanel();
+ openPanel(trigger);
+ const panelId = trigger.getAttribute("aria-controls");
+ const panel = panelId ? host.querySelector(`#${CSS.escape(panelId)}`) : null;
+
+ expect(panelId).not.toBeNull();
+ expect(panel?.getAttribute("role")).toBe("dialog");
+ // Non-modal on purpose: focus is not trapped and the editor behind stays
+ // operable, so aria-modal would lie to assistive tech about inertness.
+ expect(panel?.getAttribute("aria-modal")).toBeNull();
+ expect(panel?.id).toBe(panelId);
+ });
+});
diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx
index 50506ad46..d36336e41 100644
--- a/packages/studio/src/player/components/ShortcutsPanel.tsx
+++ b/packages/studio/src/player/components/ShortcutsPanel.tsx
@@ -1,6 +1,7 @@
-import { useState, useCallback, useRef, useEffect, memo } from "react";
+import { useState, useCallback, useId, memo } from "react";
import { formatTime, frameToSeconds } from "../lib/time";
import { Tooltip } from "../../components/ui";
+import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
const SHORTCUT_SECTIONS = [
{
@@ -108,20 +109,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
}: ShortcutsPanelProps) {
const [showShortcuts, setShowShortcuts] = useState(false);
const [jumpFrame, setJumpFrame] = useState("");
- const shortcutsPanelRef = useRef(null);
-
- useEffect(() => {
- if (!showShortcuts) return;
- const handleMouseDown = (e: MouseEvent) => {
- if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
- setShowShortcuts(false);
- }
- };
- document.addEventListener("mousedown", handleMouseDown);
- return () => {
- document.removeEventListener("mousedown", handleMouseDown);
- };
- }, [showShortcuts]);
+ const shortcutsPanelId = useId();
+ const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
+ const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
const commitJumpFrame = useCallback(() => {
if (disabled) return;
@@ -158,6 +148,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
}`}
aria-label="Shortcuts and tools"
aria-expanded={showShortcuts}
+ aria-controls={shortcutsPanelId}
>