diff --git a/packages/core/src/dom/hotkey/actions.ts b/packages/core/src/dom/hotkey/actions.ts index 8d1b4737..f9092e79 100644 --- a/packages/core/src/dom/hotkey/actions.ts +++ b/packages/core/src/dom/hotkey/actions.ts @@ -32,7 +32,7 @@ export interface HotkeyActionContext { export type HotkeyActionResolver = (context: HotkeyActionContext) => void; -export function isToggleAction(action: string): boolean { +export function isHotkeyToggleAction(action: string): boolean { return action.startsWith('toggle'); } @@ -113,7 +113,7 @@ const HOTKEY_ACTIONS: Record = { }, }; -export function resolveAction(name: string): HotkeyActionResolver | undefined { +export function resolveHotkeyAction(name: string): HotkeyActionResolver | undefined { const resolver = HOTKEY_ACTIONS[name as HotkeyActionName]; if (__DEV__ && !resolver) { diff --git a/packages/core/src/dom/hotkey/aria.ts b/packages/core/src/dom/hotkey/aria.ts index c1dc83b1..5e8761ab 100644 --- a/packages/core/src/dom/hotkey/aria.ts +++ b/packages/core/src/dom/hotkey/aria.ts @@ -1,27 +1,27 @@ -import type { ModifierKey, ParsedKeyBinding } from './hotkey'; +import type { HotkeyModifierKey, ParsedHotkeyBinding } from './hotkey'; -const ARIA_MODIFIER_MAP: Record = { +const ARIA_MODIFIER_MAP: Record = { shift: 'Shift', ctrl: 'Control', alt: 'Alt', meta: 'Meta', }; -const MODIFIER_ORDER: readonly ModifierKey[] = ['ctrl', 'shift', 'alt', 'meta']; +const MODIFIER_ORDER: readonly HotkeyModifierKey[] = ['ctrl', 'shift', 'alt', 'meta']; /** * Convert parsed key bindings to a WAI-ARIA `aria-keyshortcuts` formatted string. * * @example * ```ts - * toAriaKeyShortcut(parseKeyPattern('Ctrl+Shift+f')); + * toAriaKeyShortcut(parseHotkeyPattern('Ctrl+Shift+f')); * // "Control+Shift+f" * - * toAriaKeyShortcut([...parseKeyPattern('k'), ...parseKeyPattern('Space')]); + * toAriaKeyShortcut([...parseHotkeyPattern('k'), ...parseHotkeyPattern('Space')]); * // "k Space" * ``` */ -export function toAriaKeyShortcut(bindings: ParsedKeyBinding[]): string { +export function toAriaKeyShortcut(bindings: ParsedHotkeyBinding[]): string { return bindings .map((b) => { const parts: string[] = []; diff --git a/packages/core/src/dom/hotkey/coordinator.ts b/packages/core/src/dom/hotkey/coordinator.ts index 0b8441fe..781893b0 100644 --- a/packages/core/src/dom/hotkey/coordinator.ts +++ b/packages/core/src/dom/hotkey/coordinator.ts @@ -1,8 +1,8 @@ import { isEditableTarget, listen, resolveEventTarget } from '@videojs/utils/dom'; import { toAriaKeyShortcut } from './aria'; -import type { HotkeyOptions, ParsedKeyBinding } from './hotkey'; -import { matchesEvent, parseKeyPattern } from './hotkey'; +import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey'; +import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey'; const ACTIVATION_KEYS = new Set([' ', 'Enter']); @@ -19,7 +19,7 @@ function isInteractiveActivation(event: KeyboardEvent): boolean { } interface HotkeyBinding { - parsed: ParsedKeyBinding[]; + parsed: ParsedHotkeyBinding[]; options: HotkeyOptions; /** Registration order for DOM-order tie-breaking. */ id: number; @@ -32,7 +32,7 @@ export class HotkeyCoordinator { #disconnect: AbortController | null = null; #docDisconnect: AbortController | null = null; /** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */ - #ariaRegistry = new Map(); + #ariaRegistry = new Map(); #destroyed = false; constructor(target: HTMLElement) { @@ -40,7 +40,7 @@ export class HotkeyCoordinator { } add(options: HotkeyOptions): () => void { - const parsed = parseKeyPattern(options.keys); + const parsed = parseHotkeyPattern(options.keys); const binding: HotkeyBinding = { parsed, options, id: this.#nextId++ }; this.#bindings.push(binding); @@ -150,7 +150,7 @@ export class HotkeyCoordinator { if (isDocBinding !== isDocEvent) continue; for (const p of parsed) { - if (!matchesEvent(p, event)) continue; + if (!matchesHotkeyEvent(p, event)) continue; // Input safety: single-key shortcuts suppressed in editable fields. if (editable && p.modifiers.size === 0) continue; @@ -162,7 +162,7 @@ export class HotkeyCoordinator { } }; - #addToAriaRegistry(action: string, bindings: ParsedKeyBinding[]): void { + #addToAriaRegistry(action: string, bindings: ParsedHotkeyBinding[]): void { let existing = this.#ariaRegistry.get(action); if (!existing) { existing = []; @@ -171,7 +171,7 @@ export class HotkeyCoordinator { existing.push(...bindings); } - #removeFromAriaRegistry(action: string, bindings: ParsedKeyBinding[]): void { + #removeFromAriaRegistry(action: string, bindings: ParsedHotkeyBinding[]): void { const existing = this.#ariaRegistry.get(action); if (!existing) return; diff --git a/packages/core/src/dom/hotkey/hotkey.ts b/packages/core/src/dom/hotkey/hotkey.ts index e8c7c2e7..c33dfe68 100644 --- a/packages/core/src/dom/hotkey/hotkey.ts +++ b/packages/core/src/dom/hotkey/hotkey.ts @@ -2,10 +2,10 @@ import { isMacOS } from '@videojs/utils/dom'; import { HotkeyCoordinator } from './coordinator'; -export type ModifierKey = 'shift' | 'ctrl' | 'alt' | 'meta'; +export type HotkeyModifierKey = 'shift' | 'ctrl' | 'alt' | 'meta'; -export interface ParsedKeyBinding { - modifiers: Set; +export interface ParsedHotkeyBinding { + modifiers: Set; /** Lowercased key for matching. */ key: string; /** Original casing preserved for ARIA formatting. */ @@ -31,18 +31,18 @@ const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']); * * @example * ```ts - * parseKeyPattern('Shift+>'); + * parseHotkeyPattern('Shift+>'); * // [{ modifiers: Set('shift'), key: '>', originalKey: '>' }] * - * parseKeyPattern('0-9'); + * parseHotkeyPattern('0-9'); * // 10 bindings, one per digit * ``` */ -export function parseKeyPattern(pattern: string): ParsedKeyBinding[] { +export function parseHotkeyPattern(pattern: string): ParsedHotkeyBinding[] { // Range expansion: "0-9" → individual digit bindings. if (pattern === '0-9') { return Array.from({ length: 10 }, (_, i) => ({ - modifiers: new Set(), + modifiers: new Set(), key: String(i), originalKey: String(i), })); @@ -50,7 +50,7 @@ export function parseKeyPattern(pattern: string): ParsedKeyBinding[] { const segments = pattern.split('+'); const rawKey = segments.pop()!; - const modifiers = new Set(); + const modifiers = new Set(); for (const seg of segments) { const lower = seg.toLowerCase(); @@ -58,7 +58,7 @@ export function parseKeyPattern(pattern: string): ParsedKeyBinding[] { if (lower === 'mod') { modifiers.add(isMacOS() ? 'meta' : 'ctrl'); } else if (MODIFIER_KEYS.has(lower)) { - modifiers.add(lower as ModifierKey); + modifiers.add(lower as HotkeyModifierKey); } else if (__DEV__) { console.warn(`[vjs-hotkey] Unknown modifier: "${seg}" in pattern "${pattern}"`); } @@ -71,7 +71,7 @@ export function parseKeyPattern(pattern: string): ParsedKeyBinding[] { } /** Whether a parsed binding matches a keyboard event. */ -export function matchesEvent(binding: ParsedKeyBinding, event: KeyboardEvent): boolean { +export function matchesHotkeyEvent(binding: ParsedHotkeyBinding, event: KeyboardEvent): boolean { // IME composition filtering. if (event.key === 'Unidentified') return false; @@ -91,6 +91,11 @@ export function matchesEvent(binding: ParsedKeyBinding, event: KeyboardEvent): b const coordinators = new WeakMap(); +/** Look up the coordinator for a target element, if one exists. */ +export function findHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator | undefined { + return coordinators.get(target); +} + function getCoordinator(target: HTMLElement): HotkeyCoordinator { let coordinator = coordinators.get(target); if (!coordinator) { diff --git a/packages/core/src/dom/hotkey/tests/actions.test.ts b/packages/core/src/dom/hotkey/tests/actions.test.ts index 09a52994..58ec99c4 100644 --- a/packages/core/src/dom/hotkey/tests/actions.test.ts +++ b/packages/core/src/dom/hotkey/tests/actions.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it, vi } from 'vitest'; import type { HotkeyActionContext } from '../actions'; -import { isToggleAction, resolveAction } from '../actions'; +import { isHotkeyToggleAction, resolveHotkeyAction } from '../actions'; function mockStore(state: Record) { return { state } as HotkeyActionContext['store']; } -describe('resolveAction', () => { +describe('resolveHotkeyAction', () => { it('returns resolver for known actions', () => { - expect(resolveAction('togglePaused')).toBeTypeOf('function'); - expect(resolveAction('seekStep')).toBeTypeOf('function'); - expect(resolveAction('seekToPercent')).toBeTypeOf('function'); + expect(resolveHotkeyAction('togglePaused')).toBeTypeOf('function'); + expect(resolveHotkeyAction('seekStep')).toBeTypeOf('function'); + expect(resolveHotkeyAction('seekToPercent')).toBeTypeOf('function'); }); it('returns undefined for unknown actions', () => { - expect(resolveAction('nonexistent')).toBeUndefined(); + expect(resolveHotkeyAction('nonexistent')).toBeUndefined(); }); it('warns in __DEV__ for unknown actions', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - resolveAction('nonexistent'); + resolveHotkeyAction('nonexistent'); expect(spy).toHaveBeenCalledOnce(); expect(spy.mock.calls[0]![0]).toContain('Unknown action'); @@ -30,20 +30,20 @@ describe('resolveAction', () => { }); }); -describe('isToggleAction', () => { +describe('isHotkeyToggleAction', () => { 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); + expect(isHotkeyToggleAction('togglePaused')).toBe(true); + expect(isHotkeyToggleAction('toggleMuted')).toBe(true); + expect(isHotkeyToggleAction('toggleFullscreen')).toBe(true); + expect(isHotkeyToggleAction('toggleSubtitles')).toBe(true); + expect(isHotkeyToggleAction('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); + expect(isHotkeyToggleAction('seekStep')).toBe(false); + expect(isHotkeyToggleAction('volumeStep')).toBe(false); + expect(isHotkeyToggleAction('speedUp')).toBe(false); + expect(isHotkeyToggleAction('seekToPercent')).toBe(false); }); }); @@ -52,7 +52,7 @@ describe('togglePaused', () => { const play = vi.fn(); const store = mockStore({ paused: true, ended: false, started: false, waiting: false, play, pause: vi.fn() }); - resolveAction('togglePaused')!({ store, key: '' }); + resolveHotkeyAction('togglePaused')!({ store, key: '' }); expect(play).toHaveBeenCalledOnce(); }); @@ -61,7 +61,7 @@ describe('togglePaused', () => { const pause = vi.fn(); const store = mockStore({ paused: false, ended: false, started: true, waiting: false, play: vi.fn(), pause }); - resolveAction('togglePaused')!({ store, key: '' }); + resolveHotkeyAction('togglePaused')!({ store, key: '' }); expect(pause).toHaveBeenCalledOnce(); }); @@ -78,7 +78,7 @@ describe('toggleMuted', () => { toggleMuted, }); - resolveAction('toggleMuted')!({ store, key: '' }); + resolveHotkeyAction('toggleMuted')!({ store, key: '' }); expect(toggleMuted).toHaveBeenCalledOnce(); }); @@ -94,7 +94,7 @@ describe('toggleFullscreen', () => { exitFullscreen: vi.fn(), }); - resolveAction('toggleFullscreen')!({ store, key: '' }); + resolveHotkeyAction('toggleFullscreen')!({ store, key: '' }); expect(requestFullscreen).toHaveBeenCalledOnce(); }); @@ -108,7 +108,7 @@ describe('toggleFullscreen', () => { exitFullscreen, }); - resolveAction('toggleFullscreen')!({ store, key: '' }); + resolveHotkeyAction('toggleFullscreen')!({ store, key: '' }); expect(exitFullscreen).toHaveBeenCalledOnce(); }); @@ -119,7 +119,7 @@ describe('seekStep', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); - resolveAction('seekStep')!({ store, value: 5, key: '' }); + resolveHotkeyAction('seekStep')!({ store, value: 5, key: '' }); expect(seek).toHaveBeenCalledWith(15); }); @@ -128,7 +128,7 @@ describe('seekStep', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); - resolveAction('seekStep')!({ store, value: -5, key: '' }); + resolveHotkeyAction('seekStep')!({ store, value: -5, key: '' }); expect(seek).toHaveBeenCalledWith(5); }); @@ -137,7 +137,7 @@ describe('seekStep', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 10, duration: 100, seeking: false, seek }); - resolveAction('seekStep')!({ store, key: '' }); + resolveHotkeyAction('seekStep')!({ store, key: '' }); expect(seek).not.toHaveBeenCalled(); }); @@ -154,7 +154,7 @@ describe('volumeStep', () => { toggleMuted: vi.fn(), }); - resolveAction('volumeStep')!({ store, value: 0.05, key: '' }); + resolveHotkeyAction('volumeStep')!({ store, value: 0.05, key: '' }); expect(setVolume).toHaveBeenCalledWith(0.55); }); @@ -169,7 +169,7 @@ describe('volumeStep', () => { toggleMuted: vi.fn(), }); - resolveAction('volumeStep')!({ store, value: -0.05, key: '' }); + resolveHotkeyAction('volumeStep')!({ store, value: -0.05, key: '' }); expect(setVolume).toHaveBeenCalledWith(0.45); }); @@ -180,7 +180,7 @@ describe('speedUp', () => { const setPlaybackRate = vi.fn(); const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1, setPlaybackRate }); - resolveAction('speedUp')!({ store, key: '' }); + resolveHotkeyAction('speedUp')!({ store, key: '' }); expect(setPlaybackRate).toHaveBeenCalledWith(1.5); }); @@ -189,7 +189,7 @@ describe('speedUp', () => { const setPlaybackRate = vi.fn(); const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 2, setPlaybackRate }); - resolveAction('speedUp')!({ store, key: '' }); + resolveHotkeyAction('speedUp')!({ store, key: '' }); expect(setPlaybackRate).toHaveBeenCalledWith(1); }); @@ -200,7 +200,7 @@ describe('speedDown', () => { const setPlaybackRate = vi.fn(); const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1.5, setPlaybackRate }); - resolveAction('speedDown')!({ store, key: '' }); + resolveHotkeyAction('speedDown')!({ store, key: '' }); expect(setPlaybackRate).toHaveBeenCalledWith(1); }); @@ -209,7 +209,7 @@ describe('speedDown', () => { const setPlaybackRate = vi.fn(); const store = mockStore({ playbackRates: [1, 1.5, 2], playbackRate: 1, setPlaybackRate }); - resolveAction('speedDown')!({ store, key: '' }); + resolveHotkeyAction('speedDown')!({ store, key: '' }); expect(setPlaybackRate).toHaveBeenCalledWith(2); }); @@ -220,7 +220,7 @@ describe('seekToPercent', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); - resolveAction('seekToPercent')!({ store, value: 50, key: '' }); + resolveHotkeyAction('seekToPercent')!({ store, value: 50, key: '' }); expect(seek).toHaveBeenCalledWith(100); }); @@ -229,7 +229,7 @@ describe('seekToPercent', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); - resolveAction('seekToPercent')!({ store, key: '3' }); + resolveHotkeyAction('seekToPercent')!({ store, key: '3' }); expect(seek).toHaveBeenCalledWith(60); }); @@ -238,7 +238,7 @@ describe('seekToPercent', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 0, duration: 200, seeking: false, seek }); - resolveAction('seekToPercent')!({ store, key: 'k' }); + resolveHotkeyAction('seekToPercent')!({ store, key: 'k' }); expect(seek).not.toHaveBeenCalled(); }); @@ -247,7 +247,7 @@ describe('seekToPercent', () => { const seek = vi.fn(); const store = mockStore({ currentTime: 0, duration: 0, seeking: false, seek }); - resolveAction('seekToPercent')!({ store, value: 50, key: '' }); + resolveHotkeyAction('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 index 8172bb50..3e5bc921 100644 --- a/packages/core/src/dom/hotkey/tests/aria.test.ts +++ b/packages/core/src/dom/hotkey/tests/aria.test.ts @@ -1,37 +1,37 @@ import { describe, expect, it } from 'vitest'; import { toAriaKeyShortcut } from '../aria'; -import { parseKeyPattern } from '../hotkey'; +import { parseHotkeyPattern } from '../hotkey'; describe('toAriaKeyShortcut', () => { it('formats a simple key', () => { - expect(toAriaKeyShortcut(parseKeyPattern('k'))).toBe('k'); + expect(toAriaKeyShortcut(parseHotkeyPattern('k'))).toBe('k'); }); it('maps ctrl to Control', () => { - expect(toAriaKeyShortcut(parseKeyPattern('Ctrl+k'))).toBe('Control+k'); + expect(toAriaKeyShortcut(parseHotkeyPattern('Ctrl+k'))).toBe('Control+k'); }); it('maps shift to Shift', () => { - expect(toAriaKeyShortcut(parseKeyPattern('Shift+>'))).toBe('Shift+>'); + expect(toAriaKeyShortcut(parseHotkeyPattern('Shift+>'))).toBe('Shift+>'); }); it('formats multiple modifiers in consistent order', () => { - const result = toAriaKeyShortcut(parseKeyPattern('Ctrl+Shift+f')); + const result = toAriaKeyShortcut(parseHotkeyPattern('Ctrl+Shift+f')); expect(result).toBe('Control+Shift+f'); }); it('separates alternatives with space', () => { - const bindings = [...parseKeyPattern('k'), ...parseKeyPattern('Space')]; + const bindings = [...parseHotkeyPattern('k'), ...parseHotkeyPattern('Space')]; expect(toAriaKeyShortcut(bindings)).toBe('k Space'); }); it('preserves original key casing', () => { - expect(toAriaKeyShortcut(parseKeyPattern('ArrowRight'))).toBe('ArrowRight'); + expect(toAriaKeyShortcut(parseHotkeyPattern('ArrowRight'))).toBe('ArrowRight'); }); it('handles digit range bindings', () => { - const bindings = parseKeyPattern('0-9'); + const bindings = parseHotkeyPattern('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/hotkey.test.ts b/packages/core/src/dom/hotkey/tests/hotkey.test.ts index 7959d791..6db99b0e 100644 --- a/packages/core/src/dom/hotkey/tests/hotkey.test.ts +++ b/packages/core/src/dom/hotkey/tests/hotkey.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createHotkey, matchesEvent, parseKeyPattern } from '../hotkey'; +import { createHotkey, matchesHotkeyEvent, parseHotkeyPattern } from '../hotkey'; -describe('parseKeyPattern', () => { +describe('parseHotkeyPattern', () => { it('parses a single key with no modifiers', () => { - const result = parseKeyPattern('k'); + const result = parseHotkeyPattern('k'); expect(result).toHaveLength(1); expect(result[0]!.key).toBe('k'); @@ -13,7 +13,7 @@ describe('parseKeyPattern', () => { }); it('parses Shift modifier', () => { - const result = parseKeyPattern('Shift+>'); + const result = parseHotkeyPattern('Shift+>'); expect(result).toHaveLength(1); expect(result[0]!.key).toBe('>'); @@ -22,14 +22,14 @@ describe('parseKeyPattern', () => { }); it('parses Ctrl modifier', () => { - const result = parseKeyPattern('Ctrl+k'); + const result = parseHotkeyPattern('Ctrl+k'); expect(result).toHaveLength(1); expect(result[0]!.modifiers.has('ctrl')).toBe(true); }); it('parses multiple modifiers', () => { - const result = parseKeyPattern('Ctrl+Shift+f'); + const result = parseHotkeyPattern('Ctrl+Shift+f'); expect(result).toHaveLength(1); expect(result[0]!.modifiers.has('ctrl')).toBe(true); @@ -38,7 +38,7 @@ describe('parseKeyPattern', () => { }); it('expands 0-9 into 10 bindings', () => { - const result = parseKeyPattern('0-9'); + const result = parseHotkeyPattern('0-9'); expect(result).toHaveLength(10); for (let i = 0; i < 10; i++) { @@ -48,14 +48,14 @@ describe('parseKeyPattern', () => { }); it('stores key lowercased for matching', () => { - const result = parseKeyPattern('ArrowRight'); + const result = parseHotkeyPattern('ArrowRight'); expect(result[0]!.key).toBe('arrowright'); expect(result[0]!.originalKey).toBe('ArrowRight'); }); it('parses Space to literal space character', () => { - const result = parseKeyPattern('Space'); + const result = parseHotkeyPattern('Space'); expect(result[0]!.key).toBe(' '); expect(result[0]!.originalKey).toBe('Space'); @@ -64,7 +64,7 @@ describe('parseKeyPattern', () => { it('warns on unknown modifier in __DEV__', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - parseKeyPattern('Foo+k'); + parseHotkeyPattern('Foo+k'); expect(spy).toHaveBeenCalledOnce(); expect(spy.mock.calls[0]![0]).toContain('Unknown modifier'); @@ -73,52 +73,52 @@ describe('parseKeyPattern', () => { }); }); -describe('matchesEvent', () => { +describe('matchesHotkeyEvent', () => { 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); + const binding = parseHotkeyPattern('k')[0]!; + expect(matchesHotkeyEvent(binding, createEvent('k'))).toBe(true); }); it('matches Space pattern against literal space event', () => { - const binding = parseKeyPattern('Space')[0]!; - expect(matchesEvent(binding, createEvent(' '))).toBe(true); + const binding = parseHotkeyPattern('Space')[0]!; + expect(matchesHotkeyEvent(binding, createEvent(' '))).toBe(true); }); it('matches case-insensitively', () => { - const binding = parseKeyPattern('k')[0]!; - expect(matchesEvent(binding, createEvent('K'))).toBe(true); + const binding = parseHotkeyPattern('k')[0]!; + expect(matchesHotkeyEvent(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); + const binding = parseHotkeyPattern('k')[0]!; + expect(matchesHotkeyEvent(binding, createEvent('k', { ctrlKey: true }))).toBe(false); }); it('requires exact modifier match', () => { - const binding = parseKeyPattern('Ctrl+k')[0]!; + const binding = parseHotkeyPattern('Ctrl+k')[0]!; // Ctrl held — match. - expect(matchesEvent(binding, createEvent('k', { ctrlKey: true }))).toBe(true); + expect(matchesHotkeyEvent(binding, createEvent('k', { ctrlKey: true }))).toBe(true); // Ctrl not held — no match. - expect(matchesEvent(binding, createEvent('k'))).toBe(false); + expect(matchesHotkeyEvent(binding, createEvent('k'))).toBe(false); // Ctrl + Shift held — no match (extra modifier). - expect(matchesEvent(binding, createEvent('k', { ctrlKey: true, shiftKey: true }))).toBe(false); + expect(matchesHotkeyEvent(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); + const binding = parseHotkeyPattern('k')[0]!; + expect(matchesHotkeyEvent(binding, createEvent('Unidentified'))).toBe(false); }); it('rejects non-matching keys', () => { - const binding = parseKeyPattern('k')[0]!; - expect(matchesEvent(binding, createEvent('j'))).toBe(false); + const binding = parseHotkeyPattern('k')[0]!; + expect(matchesHotkeyEvent(binding, createEvent('j'))).toBe(false); }); }); diff --git a/packages/html/src/define/ui/hotkey.ts b/packages/html/src/define/ui/hotkey.ts new file mode 100644 index 00000000..7b4cbb8b --- /dev/null +++ b/packages/html/src/define/ui/hotkey.ts @@ -0,0 +1,10 @@ +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(HotkeyElement); + +declare global { + interface HTMLElementTagNameMap { + [HotkeyElement.tagName]: HotkeyElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 7c120465..cf96f4e5 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -27,6 +27,8 @@ export { ControlsElement } from './ui/controls/controls-element'; export { ControlsGroupElement } from './ui/controls/controls-group-element'; export { ErrorDialogElement } from './ui/error-dialog/error-dialog-element'; export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element'; +export { HotkeyElement } from './ui/hotkey/hotkey-element'; +export { HotkeyRegistryController } from './ui/hotkey/hotkey-registry-controller'; export { MediaButtonElement } from './ui/media-button-element'; // Primitives export * from './ui/media-element'; diff --git a/packages/html/src/player/context.ts b/packages/html/src/player/context.ts index 883415c9..f330de57 100644 --- a/packages/html/src/player/context.ts +++ b/packages/html/src/player/context.ts @@ -1,5 +1,6 @@ import type { AnyPlayerStore, Media, MediaContainer, PlayerStore } from '@videojs/core/dom'; -import { type Context, createContext } from '@videojs/element/context'; +import type { ReactiveControllerHost } from '@videojs/element'; +import { type Context, type ContextConsumer, createContext } from '@videojs/element/context'; // ---------------------------------------- // Player Context @@ -49,6 +50,8 @@ export interface ContainerContextValue { export type ContainerContext = Context; +export type ContainerContextConsumer = ContextConsumer; + export const containerContext = createContext( CONTAINER_CONTEXT_KEY ); diff --git a/packages/html/src/ui/captions-button/captions-button-element.ts b/packages/html/src/ui/captions-button/captions-button-element.ts index b52c8eb5..f13e8601 100644 --- a/packages/html/src/ui/captions-button/captions-button-element.ts +++ b/packages/html/src/ui/captions-button/captions-button-element.ts @@ -11,6 +11,7 @@ export class CaptionsButtonElement extends MediaButtonElement void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.style.display = 'none'; + this.#register(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#unregister(); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + // Re-register when attributes change. + if (this.isConnected) { + this.#unregister(); + this.#register(); + } + } + + #register(): void { + const store = this.#player.value; + const container = this.#container.value?.container; + if (!this.keys || !this.action || !store || !container) return; + + const resolver = resolveHotkeyAction(this.action); + if (!resolver) return; + + const { value, action } = this; + + this.#cleanup = createHotkey(container as HTMLElement, { + keys: this.keys, + action, + target: this.target, + disabled: this.disabled, + allowRepeat: !isHotkeyToggleAction(action), + onActivate: (_event, key) => { + resolver({ store, key, ...(value !== undefined && { value }) }); + }, + }); + } + + #unregister(): void { + this.#cleanup?.(); + this.#cleanup = null; + } +} diff --git a/packages/html/src/ui/hotkey/hotkey-registry-controller.ts b/packages/html/src/ui/hotkey/hotkey-registry-controller.ts new file mode 100644 index 00000000..c93fe859 --- /dev/null +++ b/packages/html/src/ui/hotkey/hotkey-registry-controller.ts @@ -0,0 +1,28 @@ +import { findHotkeyCoordinator } from '@videojs/core/dom'; +import type { ReactiveController } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import type { ContainerContextConsumer } from '../../player/context'; +import { containerContext } from '../../player/context'; +import type { PlayerControllerHost } from '../../player/player-controller'; + +/** Provides `aria-keyshortcuts` for a given hotkey action name. */ +export class HotkeyRegistryController implements ReactiveController { + #action: string; + #container: ContainerContextConsumer; + + constructor(host: PlayerControllerHost, action: string) { + this.#action = action; + this.#container = new ContextConsumer(host, { context: containerContext, subscribe: true }); + host.addController(this); + } + + get value(): string | undefined { + const container = this.#container.value?.container; + if (!container) return undefined; + return findHotkeyCoordinator(container as HTMLElement)?.getAriaKeys(this.#action); + } + + hostConnected(): void {} + hostDisconnected(): void {} +} diff --git a/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts b/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts new file mode 100644 index 00000000..93f84c7e --- /dev/null +++ b/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { HotkeyElement } from '../hotkey-element'; +import { HotkeyRegistryController } from '../hotkey-registry-controller'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-el'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('HotkeyElement', () => { + it('has the correct tag name', () => { + expect(HotkeyElement.tagName).toBe('media-hotkey'); + }); + + it('initializes with default property values', () => { + const el = createElement(HotkeyElement); + + expect(el.keys).toBe(''); + expect(el.action).toBe(''); + expect(el.value).toBeUndefined(); + expect(el.disabled).toBe(false); + expect(el.target).toBe('player'); + }); + + it('is hidden when connected', () => { + const el = createElement(HotkeyElement); + document.body.appendChild(el); + + expect(el.style.display).toBe('none'); + }); +}); + +describe('HotkeyRegistryController', () => { + it('returns undefined when no coordinator exists', () => { + const el = createElement(HotkeyElement); + document.body.appendChild(el); + + const controller = new HotkeyRegistryController(el, 'togglePaused'); + + expect(controller.value).toBeUndefined(); + }); +}); diff --git a/packages/html/src/ui/media-button-element.ts b/packages/html/src/ui/media-button-element.ts index ee74b075..c01375da 100644 --- a/packages/html/src/ui/media-button-element.ts +++ b/packages/html/src/ui/media-button-element.ts @@ -10,6 +10,7 @@ import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import type { State } from '@videojs/store'; import type { PlayerController } from '../player/player-controller'; +import { HotkeyRegistryController } from './hotkey/hotkey-registry-controller'; import { MediaElement } from './media-element'; /** Abstract base for HTML custom elements that render a media-control button. */ @@ -28,15 +29,23 @@ export abstract class MediaButtonElement exte protected abstract activate(state: InferMediaState): void; + /** Override to set the hotkey action name for `aria-keyshortcuts`. */ + protected readonly hotkeyAction: string | undefined = undefined; + get $state(): State { return this.core.state; } #disconnect: AbortController | null = null; + #hotkeyRegistry: HotkeyRegistryController | null = null; override connectedCallback(): void { super.connectedCallback(); + if (this.hotkeyAction) { + this.#hotkeyRegistry = new HotkeyRegistryController(this, this.hotkeyAction); + } + this.#disconnect = new AbortController(); const buttonProps = createButton({ @@ -76,7 +85,10 @@ export abstract class MediaButtonElement exte this.core.setMedia(media); const state = this.core.getState(); - applyElementProps(this, this.core.getAttrs?.(state) ?? {}); + applyElementProps(this, { + ...this.core.getAttrs?.(state), + 'aria-keyshortcuts': this.#hotkeyRegistry?.value, + }); applyStateDataAttrs(this, state, this.stateAttrMap); } } diff --git a/packages/html/src/ui/mute-button/mute-button-element.ts b/packages/html/src/ui/mute-button/mute-button-element.ts index 1c97aab4..c941f420 100644 --- a/packages/html/src/ui/mute-button/mute-button-element.ts +++ b/packages/html/src/ui/mute-button/mute-button-element.ts @@ -11,6 +11,7 @@ export class MuteButtonElement extends MediaButtonElement { protected readonly core = new MuteButtonCore(); protected readonly stateAttrMap = MuteButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectVolume); + protected override readonly hotkeyAction = 'toggleMuted'; protected activate(state: MediaVolumeState): void { this.core.toggle(state); diff --git a/packages/html/src/ui/pip-button/pip-button-element.ts b/packages/html/src/ui/pip-button/pip-button-element.ts index 8793bfce..866a7027 100644 --- a/packages/html/src/ui/pip-button/pip-button-element.ts +++ b/packages/html/src/ui/pip-button/pip-button-element.ts @@ -11,6 +11,7 @@ export class PiPButtonElement extends MediaButtonElement { protected readonly core = new PiPButtonCore(); protected readonly stateAttrMap = PiPButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectPiP); + protected override readonly hotkeyAction = 'togglePiP'; protected activate(state: MediaPictureInPictureState): void { this.core.toggle(state); diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts index 1b638292..4236254b 100644 --- a/packages/html/src/ui/play-button/play-button-element.ts +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -11,6 +11,7 @@ export class PlayButtonElement extends MediaButtonElement { protected readonly core = new PlayButtonCore(); protected readonly stateAttrMap = PlayButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback); + protected override readonly hotkeyAction = 'togglePaused'; protected activate(state: MediaPlaybackState): void { this.core.toggle(state);