Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/lib/timer-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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));
}
3 changes: 2 additions & 1 deletion src/pages/TimerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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':
Expand Down
35 changes: 35 additions & 0 deletions src/test/timer/timer-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading