Skip to content

Commit aa28116

Browse files
committed
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.
1 parent 5561403 commit aa28116

2 files changed

Lines changed: 165 additions & 15 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act, Profiler } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { ShortcutsPanel } from "./ShortcutsPanel";
7+
8+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9+
10+
const roots: Root[] = [];
11+
12+
afterEach(() => {
13+
for (const root of roots.splice(0)) act(() => root.unmount());
14+
document.body.innerHTML = "";
15+
});
16+
17+
function renderPanel(onRender = vi.fn()) {
18+
const host = document.createElement("div");
19+
document.body.append(host);
20+
const root = createRoot(host);
21+
roots.push(root);
22+
act(() => {
23+
root.render(
24+
<Profiler id="shortcuts-panel" onRender={onRender}>
25+
<ShortcutsPanel
26+
disabled={false}
27+
duration={10}
28+
inPoint={null}
29+
outPoint={null}
30+
setInPoint={vi.fn()}
31+
setOutPoint={vi.fn()}
32+
onSeek={vi.fn()}
33+
/>
34+
</Profiler>,
35+
);
36+
});
37+
38+
const trigger = host.querySelector<HTMLButtonElement>(
39+
'button[aria-label="Shortcuts and tools"]',
40+
)!;
41+
return { host, trigger, onRender };
42+
}
43+
44+
function pressAndClick(target: HTMLElement): void {
45+
target.dispatchEvent(
46+
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
47+
);
48+
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
49+
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
50+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
51+
}
52+
53+
function openPanel(trigger: HTMLButtonElement): void {
54+
act(() => pressAndClick(trigger));
55+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
56+
}
57+
58+
describe("ShortcutsPanel", () => {
59+
it.each(["page", "button", "panel"] as const)(
60+
"closes with Escape from the %s-focused position",
61+
(focusPosition) => {
62+
const { host, trigger } = renderPanel();
63+
openPanel(trigger);
64+
65+
let eventTarget: Document | HTMLElement;
66+
if (focusPosition === "page") {
67+
document.body.tabIndex = -1;
68+
document.body.focus();
69+
eventTarget = document;
70+
expect(document.activeElement).toBe(document.body);
71+
} else if (focusPosition === "button") {
72+
trigger.focus();
73+
eventTarget = trigger;
74+
expect(document.activeElement).toBe(trigger);
75+
} else {
76+
const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
77+
panelInput.focus();
78+
eventTarget = panelInput;
79+
expect(document.activeElement).toBe(panelInput);
80+
}
81+
82+
act(() => {
83+
eventTarget.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
84+
});
85+
86+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
87+
expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
88+
},
89+
);
90+
91+
it("closes on capture-phase pointerdown even when its default is prevented", () => {
92+
const preventDefault = (event: Event) => event.preventDefault();
93+
document.addEventListener("pointerdown", preventDefault, true);
94+
const { host, trigger } = renderPanel();
95+
openPanel(trigger);
96+
const outside = document.createElement("div");
97+
document.body.append(outside);
98+
const pointerDown = new PointerEvent("pointerdown", {
99+
bubbles: true,
100+
cancelable: true,
101+
button: 0,
102+
});
103+
104+
act(() => outside.dispatchEvent(pointerDown));
105+
106+
document.removeEventListener("pointerdown", preventDefault, true);
107+
expect(pointerDown.defaultPrevented).toBe(true);
108+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
109+
expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
110+
});
111+
112+
it("does not close on a click inside the panel", () => {
113+
const { host, trigger } = renderPanel();
114+
openPanel(trigger);
115+
const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
116+
117+
act(() => pressAndClick(panelInput));
118+
119+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
120+
expect(host.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
121+
});
122+
123+
it("toggles once per trigger click", () => {
124+
const { trigger } = renderPanel();
125+
126+
act(() => pressAndClick(trigger));
127+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
128+
129+
act(() => pressAndClick(trigger));
130+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
131+
});
132+
133+
it("does not re-render when Escape is pressed while closed", () => {
134+
const { trigger, onRender } = renderPanel();
135+
expect(onRender).toHaveBeenCalledTimes(1);
136+
137+
act(() => {
138+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
139+
});
140+
141+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
142+
expect(onRender).toHaveBeenCalledTimes(1);
143+
});
144+
145+
it("connects the trigger to the dialog with aria-controls", () => {
146+
const { host, trigger } = renderPanel();
147+
openPanel(trigger);
148+
const panelId = trigger.getAttribute("aria-controls");
149+
const panel = panelId ? host.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
150+
151+
expect(panelId).not.toBeNull();
152+
expect(panel?.getAttribute("role")).toBe("dialog");
153+
expect(panel?.getAttribute("aria-modal")).toBe("true");
154+
expect(panel?.id).toBe(panelId);
155+
});
156+
});

‎packages/studio/src/player/components/ShortcutsPanel.tsx‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useState, useCallback, useRef, useEffect, memo } from "react";
1+
import { useState, useCallback, useId, memo } from "react";
22
import { formatTime, frameToSeconds } from "../lib/time";
33
import { Tooltip } from "../../components/ui";
4+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
45

56
const SHORTCUT_SECTIONS = [
67
{
@@ -108,20 +109,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
108109
}: ShortcutsPanelProps) {
109110
const [showShortcuts, setShowShortcuts] = useState(false);
110111
const [jumpFrame, setJumpFrame] = useState("");
111-
const shortcutsPanelRef = useRef<HTMLDivElement>(null);
112-
113-
useEffect(() => {
114-
if (!showShortcuts) return;
115-
const handleMouseDown = (e: MouseEvent) => {
116-
if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
117-
setShowShortcuts(false);
118-
}
119-
};
120-
document.addEventListener("mousedown", handleMouseDown);
121-
return () => {
122-
document.removeEventListener("mousedown", handleMouseDown);
123-
};
124-
}, [showShortcuts]);
112+
const shortcutsPanelId = useId();
113+
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
114+
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
125115

126116
const commitJumpFrame = useCallback(() => {
127117
if (disabled) return;
@@ -158,6 +148,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
158148
}`}
159149
aria-label="Shortcuts and tools"
160150
aria-expanded={showShortcuts}
151+
aria-controls={shortcutsPanelId}
161152
>
162153
<svg
163154
width="11"
@@ -177,6 +168,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
177168
</Tooltip>
178169
{showShortcuts && (
179170
<div
171+
id={shortcutsPanelId}
172+
role="dialog"
173+
aria-modal="true"
180174
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
181175
style={{
182176
background: "#161618",

0 commit comments

Comments
 (0)