diff --git a/packages/core/src/dom/hotkey/actions.ts b/packages/core/src/dom/hotkey/actions.ts new file mode 100644 index 00000000..8d1b4737 --- /dev/null +++ b/packages/core/src/dom/hotkey/actions.ts @@ -0,0 +1,124 @@ +import { isUndefined } from '@videojs/utils/predicate'; + +import type { AnyPlayerStore } from '../media/types'; +import { + selectFullscreen, + selectPiP, + selectPlayback, + selectPlaybackRate, + selectTextTrack, + selectTime, + selectVolume, +} from '../store/selectors'; + +export type HotkeyActionName = + | 'togglePaused' + | 'toggleMuted' + | 'toggleFullscreen' + | 'toggleSubtitles' + | 'togglePiP' + | 'seekStep' + | 'volumeStep' + | 'speedUp' + | 'speedDown' + | 'seekToPercent'; + +export interface HotkeyActionContext { + store: AnyPlayerStore; + value?: number; + /** The matched key character (used by `seekToPercent` to derive digit). */ + key: string; +} + +export type HotkeyActionResolver = (context: HotkeyActionContext) => void; + +export function isToggleAction(action: string): boolean { + return action.startsWith('toggle'); +} + +const HOTKEY_ACTIONS: Record = { + togglePaused({ store }) { + const playback = selectPlayback(store.state); + if (!playback) return; + playback.paused ? playback.play() : playback.pause(); + }, + + toggleMuted({ store }) { + selectVolume(store.state)?.toggleMuted(); + }, + + toggleFullscreen({ store }) { + const fs = selectFullscreen(store.state); + if (!fs) return; + fs.fullscreen ? fs.exitFullscreen() : fs.requestFullscreen(); + }, + + toggleSubtitles({ store }) { + selectTextTrack(store.state)?.toggleSubtitles(); + }, + + togglePiP({ store }) { + const pip = selectPiP(store.state); + if (!pip) return; + pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture(); + }, + + seekStep({ store, value }) { + if (isUndefined(value)) return; + const time = selectTime(store.state); + if (!time) return; + time.seek(time.currentTime + value); + }, + + volumeStep({ store, value }) { + if (isUndefined(value)) return; + const vol = selectVolume(store.state); + if (!vol) return; + vol.setVolume(vol.volume + value); + }, + + speedUp({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1; + rate.setPlaybackRate(playbackRates[next]!); + }, + + speedDown({ store }) { + const rate = selectPlaybackRate(store.state); + if (!rate) return; + const { playbackRates, playbackRate } = rate; + const idx = playbackRates.indexOf(playbackRate); + const next = idx <= 0 ? playbackRates.length - 1 : idx - 1; + rate.setPlaybackRate(playbackRates[next]!); + }, + + seekToPercent({ store, value, key }) { + const time = selectTime(store.state); + if (!time || time.duration <= 0) return; + + let percent: number; + + if (!isUndefined(value)) { + percent = value; + } else if (key >= '0' && key <= '9') { + percent = Number(key) * 10; + } else { + return; + } + + time.seek((percent / 100) * time.duration); + }, +}; + +export function resolveAction(name: string): HotkeyActionResolver | undefined { + const resolver = HOTKEY_ACTIONS[name as HotkeyActionName]; + + if (__DEV__ && !resolver) { + console.warn(`[vjs-hotkey] Unknown action: "${name}"`); + } + + return resolver; +} diff --git a/packages/core/src/dom/hotkey/aria.ts b/packages/core/src/dom/hotkey/aria.ts new file mode 100644 index 00000000..c1dc83b1 --- /dev/null +++ b/packages/core/src/dom/hotkey/aria.ts @@ -0,0 +1,39 @@ +import type { ModifierKey, ParsedKeyBinding } from './hotkey'; + +const ARIA_MODIFIER_MAP: Record = { + shift: 'Shift', + ctrl: 'Control', + alt: 'Alt', + meta: 'Meta', +}; + +const MODIFIER_ORDER: readonly ModifierKey[] = ['ctrl', 'shift', 'alt', 'meta']; + +/** + * Convert parsed key bindings to a WAI-ARIA `aria-keyshortcuts` formatted string. + * + * @example + * ```ts + * toAriaKeyShortcut(parseKeyPattern('Ctrl+Shift+f')); + * // "Control+Shift+f" + * + * toAriaKeyShortcut([...parseKeyPattern('k'), ...parseKeyPattern('Space')]); + * // "k Space" + * ``` + */ +export function toAriaKeyShortcut(bindings: ParsedKeyBinding[]): string { + return bindings + .map((b) => { + const parts: string[] = []; + + for (const mod of MODIFIER_ORDER) { + if (b.modifiers.has(mod)) { + parts.push(ARIA_MODIFIER_MAP[mod]); + } + } + + parts.push(b.originalKey); + return parts.join('+'); + }) + .join(' '); +} diff --git a/packages/core/src/dom/hotkey/coordinator.ts b/packages/core/src/dom/hotkey/coordinator.ts new file mode 100644 index 00000000..0b8441fe --- /dev/null +++ b/packages/core/src/dom/hotkey/coordinator.ts @@ -0,0 +1,186 @@ +import { isEditableTarget, listen, resolveEventTarget } from '@videojs/utils/dom'; + +import { toAriaKeyShortcut } from './aria'; +import type { HotkeyOptions, ParsedKeyBinding } from './hotkey'; +import { matchesEvent, parseKeyPattern } from './hotkey'; + +const ACTIVATION_KEYS = new Set([' ', 'Enter']); + +/** Whether the event is an activation key on an interactive element (button, slider). */ +function isInteractiveActivation(event: KeyboardEvent): boolean { + if (!ACTIVATION_KEYS.has(event.key)) return false; + + const target = resolveEventTarget(event); + if (!(target instanceof HTMLElement)) return false; + if (target instanceof HTMLButtonElement) return true; + + const role = target.getAttribute('role'); + return role === 'button' || role === 'slider'; +} + +interface HotkeyBinding { + parsed: ParsedKeyBinding[]; + options: HotkeyOptions; + /** Registration order for DOM-order tie-breaking. */ + id: number; +} + +export class HotkeyCoordinator { + #target: HTMLElement; + #bindings: HotkeyBinding[] = []; + #nextId = 0; + #disconnect: AbortController | null = null; + #docDisconnect: AbortController | null = null; + /** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */ + #ariaRegistry = new Map(); + #destroyed = false; + + constructor(target: HTMLElement) { + this.#target = target; + } + + add(options: HotkeyOptions): () => void { + const parsed = parseKeyPattern(options.keys); + const binding: HotkeyBinding = { parsed, options, id: this.#nextId++ }; + + this.#bindings.push(binding); + this.#sortBindings(); + + if (options.action) { + this.#addToAriaRegistry(options.action, parsed); + } + + // Lazily connect listeners. + if (options.target === 'document') { + this.#connectDocument(); + } else { + this.#connect(); + } + + let removed = false; + return () => { + if (removed) return; + removed = true; + + const idx = this.#bindings.indexOf(binding); + if (idx !== -1) this.#bindings.splice(idx, 1); + + if (options.action) { + this.#removeFromAriaRegistry(options.action, parsed); + } + + this.#maybeDisconnect(); + }; + } + + getAriaKeys(action: string): string | undefined { + const bindings = this.#ariaRegistry.get(action); + if (!bindings?.length) return undefined; + return toAriaKeyShortcut(bindings); + } + + destroy(): void { + if (this.#destroyed) return; + this.#destroyed = true; + this.#disconnect?.abort(); + this.#disconnect = null; + this.#docDisconnect?.abort(); + this.#docDisconnect = null; + this.#bindings = []; + this.#ariaRegistry.clear(); + } + + // --- Private --- + + #sortBindings(): void { + this.#bindings.sort((a, b) => { + // Higher specificity (more modifiers) first. + const specDiff = b.parsed[0]!.modifiers.size - a.parsed[0]!.modifiers.size; + if (specDiff !== 0) return specDiff; + // Then registration order. + return a.id - b.id; + }); + } + + #connect(): void { + if (this.#disconnect) return; + this.#disconnect = new AbortController(); + listen(this.#target, 'keydown', this.#handleEvent, { signal: this.#disconnect.signal }); + } + + #connectDocument(): void { + if (this.#docDisconnect) return; + this.#docDisconnect = new AbortController(); + listen(document, 'keydown', this.#handleEvent, { signal: this.#docDisconnect.signal }); + } + + #maybeDisconnect(): void { + const hasPlayer = this.#bindings.some((b) => b.options.target !== 'document'); + const hasDoc = this.#bindings.some((b) => b.options.target === 'document'); + + if (!hasPlayer) { + this.#disconnect?.abort(); + this.#disconnect = null; + } + + if (!hasDoc) { + this.#docDisconnect?.abort(); + this.#docDisconnect = null; + } + } + + #handleEvent = (event: KeyboardEvent): void => { + // IME composition filtering. + if (event.key === 'Unidentified') return; + + // Let interactive elements handle their own activation keys. + if (isInteractiveActivation(event)) return; + + const editable = isEditableTarget(event); + + for (const binding of this.#bindings) { + const { options, parsed } = binding; + + if (options.disabled) continue; + if (event.repeat && options.allowRepeat === false) continue; + + // Only consider bindings matching the event's target scope. + const isDocBinding = options.target === 'document'; + const isDocEvent = event.currentTarget === document; + if (isDocBinding !== isDocEvent) continue; + + for (const p of parsed) { + if (!matchesEvent(p, event)) continue; + + // Input safety: single-key shortcuts suppressed in editable fields. + if (editable && p.modifiers.size === 0) continue; + + event.preventDefault(); + options.onActivate(event, p.originalKey); + return; + } + } + }; + + #addToAriaRegistry(action: string, bindings: ParsedKeyBinding[]): void { + let existing = this.#ariaRegistry.get(action); + if (!existing) { + existing = []; + this.#ariaRegistry.set(action, existing); + } + existing.push(...bindings); + } + + #removeFromAriaRegistry(action: string, bindings: ParsedKeyBinding[]): void { + const existing = this.#ariaRegistry.get(action); + if (!existing) return; + + const filtered = existing.filter((b) => !bindings.includes(b)); + + if (filtered.length === 0) { + this.#ariaRegistry.delete(action); + } else { + this.#ariaRegistry.set(action, filtered); + } + } +} diff --git a/packages/core/src/dom/hotkey/hotkey.ts b/packages/core/src/dom/hotkey/hotkey.ts new file mode 100644 index 00000000..e8c7c2e7 --- /dev/null +++ b/packages/core/src/dom/hotkey/hotkey.ts @@ -0,0 +1,122 @@ +import { isMacOS } from '@videojs/utils/dom'; + +import { HotkeyCoordinator } from './coordinator'; + +export type ModifierKey = 'shift' | 'ctrl' | 'alt' | 'meta'; + +export interface ParsedKeyBinding { + modifiers: Set; + /** Lowercased key for matching. */ + key: string; + /** Original casing preserved for ARIA formatting. */ + originalKey: string; +} + +export interface HotkeyOptions { + keys: string; + onActivate: (event: KeyboardEvent, key: string) => void; + /** Where to listen — `'player'` (container) or `'document'`. */ + target?: 'player' | 'document'; + /** Whether `event.repeat` should fire the callback. */ + allowRepeat?: boolean; + disabled?: boolean; + /** Action name for the ARIA registry. */ + action?: string; +} + +const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']); + +/** + * Parse a key pattern string into one or more bindings. + * + * @example + * ```ts + * parseKeyPattern('Shift+>'); + * // [{ modifiers: Set('shift'), key: '>', originalKey: '>' }] + * + * parseKeyPattern('0-9'); + * // 10 bindings, one per digit + * ``` + */ +export function parseKeyPattern(pattern: string): ParsedKeyBinding[] { + // Range expansion: "0-9" → individual digit bindings. + if (pattern === '0-9') { + return Array.from({ length: 10 }, (_, i) => ({ + modifiers: new Set(), + key: String(i), + originalKey: String(i), + })); + } + + const segments = pattern.split('+'); + const rawKey = segments.pop()!; + const modifiers = new Set(); + + for (const seg of segments) { + const lower = seg.toLowerCase(); + + if (lower === 'mod') { + modifiers.add(isMacOS() ? 'meta' : 'ctrl'); + } else if (MODIFIER_KEYS.has(lower)) { + modifiers.add(lower as ModifierKey); + } else if (__DEV__) { + console.warn(`[vjs-hotkey] Unknown modifier: "${seg}" in pattern "${pattern}"`); + } + } + + // "Space" is the readable name but KeyboardEvent.key is " ". + const key = rawKey === 'Space' ? ' ' : rawKey.toLowerCase(); + + return [{ modifiers, key, originalKey: rawKey }]; +} + +/** Whether a parsed binding matches a keyboard event. */ +export function matchesEvent(binding: ParsedKeyBinding, event: KeyboardEvent): boolean { + // IME composition filtering. + if (event.key === 'Unidentified') return false; + + // Case-insensitive key comparison. + if (event.key.toLowerCase() !== binding.key) return false; + + // Exact modifier matching — all four must agree. + if (event.shiftKey !== binding.modifiers.has('shift')) return false; + if (event.ctrlKey !== binding.modifiers.has('ctrl')) return false; + if (event.altKey !== binding.modifiers.has('alt')) return false; + if (event.metaKey !== binding.modifiers.has('meta')) return false; + + return true; +} + +// --- Coordinator management --- + +const coordinators = new WeakMap(); + +function getCoordinator(target: HTMLElement): HotkeyCoordinator { + let coordinator = coordinators.get(target); + if (!coordinator) { + coordinator = new HotkeyCoordinator(target); + coordinators.set(target, coordinator); + } + return coordinator; +} + +/** + * Register a hotkey binding on a target element. + * + * @example + * ```ts + * const cleanup = createHotkey(container, { + * keys: 'k', + * onActivate: () => store.paused ? store.play() : store.pause(), + * }); + * + * // Later: remove the binding + * cleanup(); + * ``` + * + * @returns A cleanup function that removes the binding. + */ +export function createHotkey(target: HTMLElement, options: HotkeyOptions): () => void { + const coordinator = getCoordinator(target); + return coordinator.add(options); +} diff --git a/packages/core/src/dom/hotkey/tests/actions.test.ts b/packages/core/src/dom/hotkey/tests/actions.test.ts new file mode 100644 index 00000000..09a52994 --- /dev/null +++ b/packages/core/src/dom/hotkey/tests/actions.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { HotkeyActionContext } from '../actions'; +import { isToggleAction, resolveAction } from '../actions'; + +function mockStore(state: Record) { + return { state } as HotkeyActionContext['store']; +} + +describe('resolveAction', () => { + it('returns resolver for known actions', () => { + expect(resolveAction('togglePaused')).toBeTypeOf('function'); + expect(resolveAction('seekStep')).toBeTypeOf('function'); + expect(resolveAction('seekToPercent')).toBeTypeOf('function'); + }); + + it('returns undefined for unknown actions', () => { + expect(resolveAction('nonexistent')).toBeUndefined(); + }); + + it('warns in __DEV__ for unknown actions', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + resolveAction('nonexistent'); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0]![0]).toContain('Unknown action'); + + spy.mockRestore(); + }); +}); + +describe('isToggleAction', () => { + it('returns true for toggle actions', () => { + expect(isToggleAction('togglePaused')).toBe(true); + expect(isToggleAction('toggleMuted')).toBe(true); + expect(isToggleAction('toggleFullscreen')).toBe(true); + expect(isToggleAction('toggleSubtitles')).toBe(true); + expect(isToggleAction('togglePiP')).toBe(true); + }); + + it('returns false for non-toggle actions', () => { + expect(isToggleAction('seekStep')).toBe(false); + expect(isToggleAction('volumeStep')).toBe(false); + expect(isToggleAction('speedUp')).toBe(false); + expect(isToggleAction('seekToPercent')).toBe(false); + }); +}); + +describe('togglePaused', () => { + it('calls play() when paused', () => { + const play = vi.fn(); + const store = mockStore({ paused: true, ended: false, started: false, waiting: false, play, pause: vi.fn() }); + + resolveAction('togglePaused')!({ store, key: '' }); + + expect(play).toHaveBeenCalledOnce(); + }); + + it('calls pause() when playing', () => { + const pause = vi.fn(); + const store = mockStore({ paused: false, ended: false, started: true, waiting: false, play: vi.fn(), pause }); + + resolveAction('togglePaused')!({ store, key: '' }); + + expect(pause).toHaveBeenCalledOnce(); + }); +}); + +describe('toggleMuted', () => { + it('calls toggleMuted()', () => { + const toggleMuted = vi.fn(); + const store = mockStore({ + volume: 1, + muted: false, + volumeAvailability: 'available', + setVolume: vi.fn(), + toggleMuted, + }); + + resolveAction('toggleMuted')!({ store, key: '' }); + + expect(toggleMuted).toHaveBeenCalledOnce(); + }); +}); + +describe('toggleFullscreen', () => { + it('calls requestFullscreen() when not fullscreen', () => { + const requestFullscreen = vi.fn(); + const store = mockStore({ + fullscreen: false, + fullscreenAvailability: 'available', + requestFullscreen, + exitFullscreen: vi.fn(), + }); + + resolveAction('toggleFullscreen')!({ store, key: '' }); + + expect(requestFullscreen).toHaveBeenCalledOnce(); + }); + + it('calls exitFullscreen() when fullscreen', () => { + const exitFullscreen = vi.fn(); + const store = mockStore({ + fullscreen: true, + fullscreenAvailability: 'available', + requestFullscreen: vi.fn(), + exitFullscreen, + }); + + resolveAction('toggleFullscreen')!({ store, key: '' }); + + expect(exitFullscreen).toHaveBeenCalledOnce(); + }); +}); + +describe('seekStep', () => { + it('seeks forward by value', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); + + resolveAction('seekStep')!({ store, value: 5, key: '' }); + + expect(seek).toHaveBeenCalledWith(15); + }); + + it('seeks backward by negative value', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); + + resolveAction('seekStep')!({ store, value: -5, key: '' }); + + expect(seek).toHaveBeenCalledWith(5); + }); + + it('no-ops without value', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); + + resolveAction('seekStep')!({ store, key: '' }); + + expect(seek).not.toHaveBeenCalled(); + }); +}); + +describe('volumeStep', () => { + it('increases volume by value', () => { + const setVolume = vi.fn(); + const store = mockStore({ + volume: 0.5, + muted: false, + volumeAvailability: 'available', + setVolume, + toggleMuted: vi.fn(), + }); + + resolveAction('volumeStep')!({ store, value: 0.05, key: '' }); + + expect(setVolume).toHaveBeenCalledWith(0.55); + }); + + it('decreases volume by negative value', () => { + const setVolume = vi.fn(); + const store = mockStore({ + volume: 0.5, + muted: false, + volumeAvailability: 'available', + setVolume, + toggleMuted: vi.fn(), + }); + + resolveAction('volumeStep')!({ store, value: -0.05, key: '' }); + + expect(setVolume).toHaveBeenCalledWith(0.45); + }); +}); + +describe('speedUp', () => { + it('steps to next rate', () => { + const setPlaybackRate = vi.fn(); + const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1, setPlaybackRate }); + + resolveAction('speedUp')!({ store, key: '' }); + + expect(setPlaybackRate).toHaveBeenCalledWith(1.5); + }); + + it('wraps to first rate at end', () => { + const setPlaybackRate = vi.fn(); + const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 2, setPlaybackRate }); + + resolveAction('speedUp')!({ store, key: '' }); + + expect(setPlaybackRate).toHaveBeenCalledWith(1); + }); +}); + +describe('speedDown', () => { + it('steps to previous rate', () => { + const setPlaybackRate = vi.fn(); + const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1.5, setPlaybackRate }); + + resolveAction('speedDown')!({ store, key: '' }); + + expect(setPlaybackRate).toHaveBeenCalledWith(1); + }); + + it('wraps to last rate at beginning', () => { + const setPlaybackRate = vi.fn(); + const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1, setPlaybackRate }); + + resolveAction('speedDown')!({ store, key: '' }); + + expect(setPlaybackRate).toHaveBeenCalledWith(2); + }); +}); + +describe('seekToPercent', () => { + it('seeks to explicit value percentage', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); + + resolveAction('seekToPercent')!({ store, value: 50, key: '' }); + + expect(seek).toHaveBeenCalledWith(100); + }); + + it('derives percentage from digit key', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); + + resolveAction('seekToPercent')!({ store, key: '3' }); + + expect(seek).toHaveBeenCalledWith(60); + }); + + it('no-ops for non-digit key without value', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); + + resolveAction('seekToPercent')!({ store, key: 'k' }); + + expect(seek).not.toHaveBeenCalled(); + }); + + it('no-ops when duration is 0', () => { + const seek = vi.fn(); + const store = mockStore({ currentTime: 0, duration: 0, seeking: false, seek }); + + resolveAction('seekToPercent')!({ store, value: 50, key: '' }); + + expect(seek).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/dom/hotkey/tests/aria.test.ts b/packages/core/src/dom/hotkey/tests/aria.test.ts new file mode 100644 index 00000000..8172bb50 --- /dev/null +++ b/packages/core/src/dom/hotkey/tests/aria.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { toAriaKeyShortcut } from '../aria'; +import { parseKeyPattern } from '../hotkey'; + +describe('toAriaKeyShortcut', () => { + it('formats a simple key', () => { + expect(toAriaKeyShortcut(parseKeyPattern('k'))).toBe('k'); + }); + + it('maps ctrl to Control', () => { + expect(toAriaKeyShortcut(parseKeyPattern('Ctrl+k'))).toBe('Control+k'); + }); + + it('maps shift to Shift', () => { + expect(toAriaKeyShortcut(parseKeyPattern('Shift+>'))).toBe('Shift+>'); + }); + + it('formats multiple modifiers in consistent order', () => { + const result = toAriaKeyShortcut(parseKeyPattern('Ctrl+Shift+f')); + expect(result).toBe('Control+Shift+f'); + }); + + it('separates alternatives with space', () => { + const bindings = [...parseKeyPattern('k'), ...parseKeyPattern('Space')]; + expect(toAriaKeyShortcut(bindings)).toBe('k Space'); + }); + + it('preserves original key casing', () => { + expect(toAriaKeyShortcut(parseKeyPattern('ArrowRight'))).toBe('ArrowRight'); + }); + + it('handles digit range bindings', () => { + const bindings = parseKeyPattern('0-9'); + const result = toAriaKeyShortcut(bindings); + expect(result).toBe('0 1 2 3 4 5 6 7 8 9'); + }); +}); diff --git a/packages/core/src/dom/hotkey/tests/coordinator.test.ts b/packages/core/src/dom/hotkey/tests/coordinator.test.ts new file mode 100644 index 00000000..24a7b621 --- /dev/null +++ b/packages/core/src/dom/hotkey/tests/coordinator.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HotkeyCoordinator } from '../coordinator'; + +function keydown(target: EventTarget, key: string, mods?: Partial): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...mods }); + target.dispatchEvent(event); + return event; +} + +describe('HotkeyCoordinator', () => { + let container: HTMLElement; + let coordinator: HotkeyCoordinator; + + afterEach(() => { + coordinator?.destroy(); + container?.remove(); + }); + + function setup() { + container = document.createElement('div'); + document.body.appendChild(container); + coordinator = new HotkeyCoordinator(container); + return coordinator; + } + + describe('add', () => { + it('returns a cleanup function', () => { + const c = setup(); + const remove = c.add({ keys: 'k', onActivate: vi.fn() }); + + expect(typeof remove).toBe('function'); + + remove(); + }); + + it('fires onActivate for matching keydown', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + keydown(container, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + + it('calls preventDefault on match', () => { + const c = setup(); + c.add({ keys: 'k', onActivate: vi.fn() }); + + const event = keydown(container, 'k'); + + expect(event.defaultPrevented).toBe(true); + }); + + it('does not fire for non-matching keys', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + keydown(container, 'j'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('removes binding on cleanup', () => { + const c = setup(); + const onActivate = vi.fn(); + const remove = c.add({ keys: 'k', onActivate }); + + remove(); + keydown(container, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('cleanup is idempotent', () => { + const c = setup(); + const remove = c.add({ keys: 'k', onActivate: vi.fn() }); + + remove(); + remove(); + }); + }); + + describe('conflict resolution', () => { + it('fires binding with more modifiers first (specificity)', () => { + const c = setup(); + const first = vi.fn(); + const second = vi.fn(); + + c.add({ keys: 'k', onActivate: second }); + c.add({ keys: 'Ctrl+k', onActivate: first }); + + keydown(container, 'k', { ctrlKey: true }); + + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + }); + + it('fires first-registered binding for equal specificity', () => { + const c = setup(); + const first = vi.fn(); + const second = vi.fn(); + + c.add({ keys: 'k', onActivate: first }); + c.add({ keys: 'k', onActivate: second }); + + keydown(container, 'k'); + + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + }); + + it('only fires one binding per event', () => { + const c = setup(); + const first = vi.fn(); + const second = vi.fn(); + + c.add({ keys: 'k', onActivate: first }); + c.add({ keys: 'k', onActivate: second }); + + keydown(container, 'k'); + + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + }); + }); + + describe('input safety', () => { + it('suppresses single-key shortcuts in text inputs', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + const input = document.createElement('input'); + input.type = 'text'; + container.appendChild(input); + + keydown(input, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('allows modifier combos in text inputs', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'Ctrl+k', onActivate }); + + const input = document.createElement('input'); + input.type = 'text'; + container.appendChild(input); + + keydown(input, 'k', { ctrlKey: true }); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + }); + + describe('interactive element priority', () => { + it('skips Space on button elements', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'Space', onActivate }); + + const button = document.createElement('button'); + container.appendChild(button); + + keydown(button, ' '); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('skips Enter on role="button" elements', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'Enter', onActivate }); + + const div = document.createElement('div'); + div.setAttribute('role', 'button'); + container.appendChild(div); + + keydown(div, 'Enter'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('fires for non-activation keys on buttons', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + const button = document.createElement('button'); + container.appendChild(button); + + keydown(button, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + }); + + describe('repeat handling', () => { + it('ignores repeat events when allowRepeat is false', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate, allowRepeat: false }); + + keydown(container, 'k', { repeat: true }); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('fires repeat events when allowRepeat is true', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate, allowRepeat: true }); + + keydown(container, 'k', { repeat: true }); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + + it('allows repeat by default', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + keydown(container, 'k', { repeat: true }); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + }); + + describe('disabled', () => { + it('skips disabled bindings', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate, disabled: true }); + + keydown(container, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + }); + + describe('lifecycle', () => { + it('creates listener on first binding', () => { + const c = setup(); + const onActivate = vi.fn(); + + // Before any binding, keydown should do nothing. + keydown(container, 'k'); + + c.add({ keys: 'k', onActivate }); + keydown(container, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + + it('removes listener when last binding removed', () => { + const c = setup(); + const onActivate = vi.fn(); + const remove = c.add({ keys: 'k', onActivate }); + + remove(); + + // Re-add to verify listener was removed (new listener needed). + const onActivate2 = vi.fn(); + c.add({ keys: 'k', onActivate: onActivate2 }); + keydown(container, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + expect(onActivate2).toHaveBeenCalledOnce(); + }); + + it('destroy is idempotent', () => { + const c = setup(); + c.destroy(); + c.destroy(); + }); + + it('does not fire after destroy', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate }); + + c.destroy(); + keydown(container, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + }); + + describe('document target', () => { + it('listens on document for document-scoped bindings', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate, target: 'document' }); + + keydown(document, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + + it('cleans up document listener when last doc binding removed', () => { + const c = setup(); + const onActivate = vi.fn(); + const remove = c.add({ keys: 'k', onActivate, target: 'document' }); + + remove(); + keydown(document, 'k'); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('fires document-scoped binding once when key originates in container', () => { + const c = setup(); + const onActivate = vi.fn(); + c.add({ keys: 'k', onActivate, target: 'document' }); + + // Key in container bubbles to document — doc listener fires once. + keydown(container, 'k'); + + expect(onActivate).toHaveBeenCalledOnce(); + }); + }); + + describe('ARIA registry', () => { + it('returns undefined for unregistered action', () => { + const c = setup(); + expect(c.getAriaKeys('togglePaused')).toBeUndefined(); + }); + + it('returns formatted key for registered action', () => { + const c = setup(); + c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + + expect(c.getAriaKeys('togglePaused')).toBe('k'); + }); + + it('accumulates multiple bindings for same action', () => { + const c = setup(); + c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + c.add({ keys: 'Space', onActivate: vi.fn(), action: 'togglePaused' }); + + expect(c.getAriaKeys('togglePaused')).toBe('k Space'); + }); + + it('removes from registry on cleanup', () => { + const c = setup(); + const remove = c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + + remove(); + + expect(c.getAriaKeys('togglePaused')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/dom/hotkey/tests/hotkey.test.ts b/packages/core/src/dom/hotkey/tests/hotkey.test.ts new file mode 100644 index 00000000..7959d791 --- /dev/null +++ b/packages/core/src/dom/hotkey/tests/hotkey.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createHotkey, matchesEvent, parseKeyPattern } from '../hotkey'; + +describe('parseKeyPattern', () => { + it('parses a single key with no modifiers', () => { + const result = parseKeyPattern('k'); + + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('k'); + expect(result[0]!.originalKey).toBe('k'); + expect(result[0]!.modifiers.size).toBe(0); + }); + + it('parses Shift modifier', () => { + const result = parseKeyPattern('Shift+>'); + + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('>'); + expect(result[0]!.modifiers.has('shift')).toBe(true); + expect(result[0]!.modifiers.size).toBe(1); + }); + + it('parses Ctrl modifier', () => { + const result = parseKeyPattern('Ctrl+k'); + + expect(result).toHaveLength(1); + expect(result[0]!.modifiers.has('ctrl')).toBe(true); + }); + + it('parses multiple modifiers', () => { + const result = parseKeyPattern('Ctrl+Shift+f'); + + expect(result).toHaveLength(1); + expect(result[0]!.modifiers.has('ctrl')).toBe(true); + expect(result[0]!.modifiers.has('shift')).toBe(true); + expect(result[0]!.modifiers.size).toBe(2); + }); + + it('expands 0-9 into 10 bindings', () => { + const result = parseKeyPattern('0-9'); + + expect(result).toHaveLength(10); + for (let i = 0; i < 10; i++) { + expect(result[i]!.key).toBe(String(i)); + expect(result[i]!.modifiers.size).toBe(0); + } + }); + + it('stores key lowercased for matching', () => { + const result = parseKeyPattern('ArrowRight'); + + expect(result[0]!.key).toBe('arrowright'); + expect(result[0]!.originalKey).toBe('ArrowRight'); + }); + + it('parses Space to literal space character', () => { + const result = parseKeyPattern('Space'); + + expect(result[0]!.key).toBe(' '); + expect(result[0]!.originalKey).toBe('Space'); + }); + + it('warns on unknown modifier in __DEV__', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + parseKeyPattern('Foo+k'); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0]![0]).toContain('Unknown modifier'); + + spy.mockRestore(); + }); +}); + +describe('matchesEvent', () => { + function createEvent(key: string, mods?: Partial): KeyboardEvent { + return new KeyboardEvent('keydown', { key, bubbles: true, ...mods }); + } + + it('matches a simple key', () => { + const binding = parseKeyPattern('k')[0]!; + expect(matchesEvent(binding, createEvent('k'))).toBe(true); + }); + + it('matches Space pattern against literal space event', () => { + const binding = parseKeyPattern('Space')[0]!; + expect(matchesEvent(binding, createEvent(' '))).toBe(true); + }); + + it('matches case-insensitively', () => { + const binding = parseKeyPattern('k')[0]!; + expect(matchesEvent(binding, createEvent('K'))).toBe(true); + }); + + it('rejects when extra modifiers are held', () => { + const binding = parseKeyPattern('k')[0]!; + expect(matchesEvent(binding, createEvent('k', { ctrlKey: true }))).toBe(false); + }); + + it('requires exact modifier match', () => { + const binding = parseKeyPattern('Ctrl+k')[0]!; + + // Ctrl held — match. + expect(matchesEvent(binding, createEvent('k', { ctrlKey: true }))).toBe(true); + + // Ctrl not held — no match. + expect(matchesEvent(binding, createEvent('k'))).toBe(false); + + // Ctrl + Shift held — no match (extra modifier). + expect(matchesEvent(binding, createEvent('k', { ctrlKey: true, shiftKey: true }))).toBe(false); + }); + + it('skips Unidentified key events (IME)', () => { + const binding = parseKeyPattern('k')[0]!; + expect(matchesEvent(binding, createEvent('Unidentified'))).toBe(false); + }); + + it('rejects non-matching keys', () => { + const binding = parseKeyPattern('k')[0]!; + expect(matchesEvent(binding, createEvent('j'))).toBe(false); + }); +}); + +describe('createHotkey', () => { + let container: HTMLElement; + + afterEach(() => { + container?.remove(); + }); + + function setup() { + container = document.createElement('div'); + document.body.appendChild(container); + return container; + } + + it('returns a cleanup function', () => { + const el = setup(); + const cleanup = createHotkey(el, { keys: 'k', onActivate: vi.fn() }); + + expect(typeof cleanup).toBe('function'); + + cleanup(); + }); + + it('calls onActivate when matching key is pressed', () => { + const el = setup(); + const onActivate = vi.fn(); + const cleanup = createHotkey(el, { keys: 'k', onActivate }); + + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true })); + + expect(onActivate).toHaveBeenCalledOnce(); + + cleanup(); + }); + + it('does not fire after cleanup', () => { + const el = setup(); + const onActivate = vi.fn(); + const cleanup = createHotkey(el, { keys: 'k', onActivate }); + + cleanup(); + + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true })); + + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('does not fire when disabled', () => { + const el = setup(); + const onActivate = vi.fn(); + const cleanup = createHotkey(el, { keys: 'k', onActivate, disabled: true }); + + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true })); + + expect(onActivate).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('passes the matched key to onActivate', () => { + const el = setup(); + const onActivate = vi.fn(); + const cleanup = createHotkey(el, { keys: 'ArrowRight', onActivate }); + + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + expect(onActivate).toHaveBeenCalledWith(expect.any(KeyboardEvent), 'ArrowRight'); + + cleanup(); + }); +}); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 489f4b27..11dd7616 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -1,4 +1,8 @@ export * from './feature'; +export * from './hotkey/actions'; +export * from './hotkey/aria'; +export * from './hotkey/coordinator'; +export * from './hotkey/hotkey'; export * from './media/proxy'; export * from './media/types'; export * from './store/features'; diff --git a/packages/utils/src/dom/event.ts b/packages/utils/src/dom/event.ts index f419898e..56ea1d57 100644 --- a/packages/utils/src/dom/event.ts +++ b/packages/utils/src/dom/event.ts @@ -1,3 +1,9 @@ +/** Resolve the deepest event target, preferring composedPath for shadow DOM. */ +export function resolveEventTarget(event: Event): EventTarget | null { + const path = event.composedPath(); + return path.length > 0 ? path[0]! : event.target; +} + export interface OnEventOptions extends AddEventListenerOptions { /** * An AbortSignal to cancel waiting for the event. diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 90b2ccc3..8f3e56ab 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -1,11 +1,18 @@ export { animationFrame } from './animation-frame'; export { namedNodeMapToObject } from './attributes'; export { isRTL } from './direction'; -export { type OnEventOptions, onEvent } from './event'; +export { type OnEventOptions, onEvent, resolveEventTarget } from './event'; export { idleCallback } from './idle-callback'; export { listen } from './listen'; +export { isMacOS } from './platform'; export { tryHidePopover, tryShowPopover } from './popover'; -export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates'; +export { + isEditableElement, + isEditableTarget, + isHTMLAudioElement, + isHTMLMediaElement, + isHTMLVideoElement, +} from './predicates'; export { type RafThrottled, rafThrottle } from './raf-throttle'; export { applyShadowStyles, diff --git a/packages/utils/src/dom/platform.ts b/packages/utils/src/dom/platform.ts new file mode 100644 index 00000000..34b56a1a --- /dev/null +++ b/packages/utils/src/dom/platform.ts @@ -0,0 +1,3 @@ +export function isMacOS(): boolean { + return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent); +} diff --git a/packages/utils/src/dom/predicates.ts b/packages/utils/src/dom/predicates.ts index ddb003af..f96e7083 100644 --- a/packages/utils/src/dom/predicates.ts +++ b/packages/utils/src/dom/predicates.ts @@ -1,3 +1,5 @@ +import { resolveEventTarget } from './event'; + export function isHTMLVideoElement(value: unknown): value is HTMLVideoElement { return value instanceof HTMLVideoElement; } @@ -9,3 +11,25 @@ export function isHTMLAudioElement(value: unknown): value is HTMLAudioElement { export function isHTMLMediaElement(value: unknown): value is HTMLMediaElement { return value instanceof HTMLMediaElement; } + +const EDITABLE_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number']); + +export function isEditableElement(el: Element): boolean { + if (el instanceof HTMLTextAreaElement) return true; + if (el instanceof HTMLSelectElement) return true; + + if (el instanceof HTMLInputElement) { + return EDITABLE_INPUT_TYPES.has(el.type.toLowerCase()); + } + + if (!(el instanceof HTMLElement)) return false; + + const editable = el.getAttribute('contenteditable'); + return editable !== null && editable !== 'false'; +} + +/** Whether the keyboard event target is an editable element (input, textarea, etc). */ +export function isEditableTarget(event: KeyboardEvent): boolean { + const target = resolveEventTarget(event); + return target instanceof Element && isEditableElement(target); +} diff --git a/packages/utils/src/dom/tests/predicates.test.ts b/packages/utils/src/dom/tests/predicates.test.ts index 33f73375..a603d220 100644 --- a/packages/utils/src/dom/tests/predicates.test.ts +++ b/packages/utils/src/dom/tests/predicates.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates'; +import { isEditableTarget, isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates'; + +function keydown(target: EventTarget, options?: KeyboardEventInit): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, ...options }); + target.dispatchEvent(event); + return event; +} describe('DOM predicates', () => { describe('isHTMLVideoElement', () => { @@ -75,3 +81,111 @@ describe('DOM predicates', () => { }); }); }); + +describe('isEditableTarget', () => { + it('returns true for text input', () => { + const input = document.createElement('input'); + input.type = 'text'; + document.body.appendChild(input); + + const event = keydown(input); + expect(isEditableTarget(event)).toBe(true); + + input.remove(); + }); + + it('returns true for textarea', () => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + + const event = keydown(textarea); + expect(isEditableTarget(event)).toBe(true); + + textarea.remove(); + }); + + it('returns true for select', () => { + const select = document.createElement('select'); + document.body.appendChild(select); + + const event = keydown(select); + expect(isEditableTarget(event)).toBe(true); + + select.remove(); + }); + + it('returns true for contenteditable element', () => { + const div = document.createElement('div'); + div.setAttribute('contenteditable', 'true'); + document.body.appendChild(div); + + const event = keydown(div); + expect(isEditableTarget(event)).toBe(true); + + div.remove(); + }); + + it('returns false for button', () => { + const button = document.createElement('button'); + document.body.appendChild(button); + + const event = keydown(button); + expect(isEditableTarget(event)).toBe(false); + + button.remove(); + }); + + it('returns false for range input', () => { + const input = document.createElement('input'); + input.type = 'range'; + document.body.appendChild(input); + + const event = keydown(input); + expect(isEditableTarget(event)).toBe(false); + + input.remove(); + }); + + it('returns false for plain div', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + const event = keydown(div); + expect(isEditableTarget(event)).toBe(false); + + div.remove(); + }); + + it('returns true for email input', () => { + const input = document.createElement('input'); + input.type = 'email'; + document.body.appendChild(input); + + const event = keydown(input); + expect(isEditableTarget(event)).toBe(true); + + input.remove(); + }); + + it('returns true for search input', () => { + const input = document.createElement('input'); + input.type = 'search'; + document.body.appendChild(input); + + const event = keydown(input); + expect(isEditableTarget(event)).toBe(true); + + input.remove(); + }); + + it('returns false for checkbox input', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + document.body.appendChild(input); + + const event = keydown(input); + expect(isEditableTarget(event)).toBe(false); + + input.remove(); + }); +});