fix(studio): dismiss the shortcuts panel on escape and outside press

Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 18:02:09 +02:00
parent 55614033e5
commit aa2811642f
2 changed files with 165 additions and 15 deletions
@@ -0,0 +1,156 @@
// @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(
<Profiler id="shortcuts-panel" onRender={onRender}>
<ShortcutsPanel
disabled={false}
duration={10}
inPoint={null}
outPoint={null}
setInPoint={vi.fn()}
setOutPoint={vi.fn()}
onSeek={vi.fn()}
/>
</Profiler>,
);
});
const trigger = host.querySelector<HTMLButtonElement>(
'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<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
expect(panelId).not.toBeNull();
expect(panel?.getAttribute("role")).toBe("dialog");
expect(panel?.getAttribute("aria-modal")).toBe("true");
expect(panel?.id).toBe(panelId);
});
});
@@ -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<HTMLDivElement>(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}
>
<svg
width="11"
@@ -177,6 +168,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
</Tooltip>
{showShortcuts && (
<div
id={shortcutsPanelId}
role="dialog"
aria-modal="true"
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
style={{
background: "#161618",