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..95ab9c1ea
--- /dev/null
+++ b/packages/studio/src/player/components/ShortcutsPanel.test.tsx
@@ -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(
+
+
+ ,
+ );
+ });
+
+ 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");
+ expect(panel?.getAttribute("aria-modal")).toBe("true");
+ 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..9eb5e72c7 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}
>