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} > {showShortcuts && (