From 130966f1ef350707a55fe7d7934649a021003d1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:56:15 +0000 Subject: [PATCH] Keep timer shortcuts away from buttons and open menus. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space, Escape, and the arrow keys were listened for on the whole window, so they started or reset a speech while a button, select, or menu already had them. Escape after closing a menu also reset a paused speech. Those keys are now ignored when a control is using them. Co-authored-by: Gáborjáni-Szabó Ádám --- src/lib/timer-shortcuts.ts | 25 ++++++++++++++++++ src/pages/TimerPage.tsx | 3 ++- src/test/timer/timer-shortcuts.test.ts | 35 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/lib/timer-shortcuts.ts create mode 100644 src/test/timer/timer-shortcuts.test.ts diff --git a/src/lib/timer-shortcuts.ts b/src/lib/timer-shortcuts.ts new file mode 100644 index 000000000..3fa0bbc0f --- /dev/null +++ b/src/lib/timer-shortcuts.ts @@ -0,0 +1,25 @@ +const INTERACTIVE_SELECTOR = [ + 'button', + 'select', + 'a', + 'input', + 'textarea', + '[role="menu"]', + '[role="menuitem"]', + '[role="listbox"]', + '[role="option"]', + '[role="dialog"]', + '[role="combobox"]', +].join(', '); + +/** + * Timer keys are on the window. They must stay out of controls that already use + * Space, Escape, and the arrow keys, including a menu that just consumed Escape. + */ +export function timerShortcutBlocked(event: KeyboardEvent): boolean { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return true; + const target = event.target; + if (!(target instanceof Element)) return false; + if (target instanceof HTMLElement && target.isContentEditable) return true; + return Boolean(target.closest(INTERACTIVE_SELECTOR)); +} diff --git a/src/pages/TimerPage.tsx b/src/pages/TimerPage.tsx index 88b762938..c83033775 100644 --- a/src/pages/TimerPage.tsx +++ b/src/pages/TimerPage.tsx @@ -8,6 +8,7 @@ import { Progress } from '@/components/ui/progress'; import { PageHeader } from '@/components/layout/PageHeader'; import { ClockAnnouncer, RoundClock, clockTone } from '@/components/features/practice/RoundClock'; import { displaySeconds, pauseClock, remainingMs, startClock, type ClockState } from '@/lib/timer-clock'; +import { timerShortcutBlocked } from '@/lib/timer-shortcuts'; import { toast } from '@/hooks/use-toast'; import { Select, @@ -272,7 +273,7 @@ export default function TimerPage() { // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (timerShortcutBlocked(e)) return; switch (e.code) { case 'Space': diff --git a/src/test/timer/timer-shortcuts.test.ts b/src/test/timer/timer-shortcuts.test.ts new file mode 100644 index 000000000..fc5c6881f --- /dev/null +++ b/src/test/timer/timer-shortcuts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { timerShortcutBlocked } from '@/lib/timer-shortcuts'; + +function key(target: EventTarget, init: KeyboardEventInit = {}): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + Object.defineProperty(event, 'target', { value: target }); + return event; +} + +describe('timer shortcuts', () => { + it('lets a bare key through when nothing else is focused', () => { + expect(timerShortcutBlocked(key(document.body, { code: 'Space' }))).toBe(false); + }); + + it('stays out of buttons, selects, menus, and keys a control already handled', () => { + const button = document.createElement('button'); + document.body.append(button); + expect(timerShortcutBlocked(key(button, { code: 'Space' }))).toBe(true); + + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + const item = document.createElement('div'); + item.setAttribute('role', 'menuitem'); + menu.append(item); + document.body.append(menu); + expect(timerShortcutBlocked(key(item, { code: 'Escape' }))).toBe(true); + + const consumed = key(document.body, { code: 'Escape' }); + consumed.preventDefault(); + expect(timerShortcutBlocked(consumed)).toBe(true); + + const modified = key(document.body, { code: 'ArrowRight', ctrlKey: true }); + expect(timerShortcutBlocked(modified)).toBe(true); + }); +});