feat(html): add hotkeys (#1239)

This commit is contained in:
rahim
2026-04-07 01:08:16 -07:00
committed by GitHub
parent 627ea204fc
commit d9d893b726
19 changed files with 294 additions and 98 deletions
+2 -2
View File
@@ -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<HotkeyActionName, HotkeyActionResolver> = {
},
};
export function resolveAction(name: string): HotkeyActionResolver | undefined {
export function resolveHotkeyAction(name: string): HotkeyActionResolver | undefined {
const resolver = HOTKEY_ACTIONS[name as HotkeyActionName];
if (__DEV__ && !resolver) {
+6 -6
View File
@@ -1,27 +1,27 @@
import type { ModifierKey, ParsedKeyBinding } from './hotkey';
import type { HotkeyModifierKey, ParsedHotkeyBinding } from './hotkey';
const ARIA_MODIFIER_MAP: Record<ModifierKey, string> = {
const ARIA_MODIFIER_MAP: Record<HotkeyModifierKey, string> = {
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[] = [];
+8 -8
View File
@@ -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<string, ParsedKeyBinding[]>();
#ariaRegistry = new Map<string, ParsedHotkeyBinding[]>();
#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;
+15 -10
View File
@@ -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<ModifierKey>;
export interface ParsedHotkeyBinding {
modifiers: Set<HotkeyModifierKey>;
/** 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<ModifierKey>(),
modifiers: new Set<HotkeyModifierKey>(),
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<ModifierKey>();
const modifiers = new Set<HotkeyModifierKey>();
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<HTMLElement, HotkeyCoordinator>();
/** 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) {
@@ -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<string, unknown>) {
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();
});
@@ -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');
});
@@ -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<KeyboardEventInit>): 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);
});
});
+10
View File
@@ -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;
}
}
+2
View File
@@ -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';
+4 -1
View File
@@ -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<typeof CONTAINER_CONTEXT_KEY, ContainerContextValue>;
export type ContainerContextConsumer = ContextConsumer<ContainerContext, ReactiveControllerHost & HTMLElement>;
export const containerContext = createContext<ContainerContextValue, typeof CONTAINER_CONTEXT_KEY>(
CONTAINER_CONTEXT_KEY
);
@@ -11,6 +11,7 @@ export class CaptionsButtonElement extends MediaButtonElement<CaptionsButtonCore
protected readonly core = new CaptionsButtonCore();
protected readonly stateAttrMap = CaptionsButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack);
protected override readonly hotkeyAction = 'toggleSubtitles';
protected activate(state: MediaTextTrackState): void {
this.core.toggle(state);
@@ -11,6 +11,7 @@ export class FullscreenButtonElement extends MediaButtonElement<FullscreenButton
protected readonly core = new FullscreenButtonCore();
protected readonly stateAttrMap = FullscreenButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectFullscreen);
protected override readonly hotkeyAction = 'toggleFullscreen';
protected activate(state: MediaFullscreenState): void {
this.core.toggle(state);
@@ -0,0 +1,77 @@
import { createHotkey, type HotkeyActionName, isHotkeyToggleAction, resolveHotkeyAction } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { containerContext, playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class HotkeyElement extends MediaElement {
static readonly tagName = 'media-hotkey';
static override properties: PropertyDeclarationMap = {
keys: { type: String },
action: { type: String },
value: { type: Number },
disabled: { type: Boolean },
target: { type: String },
};
keys = '';
action: HotkeyActionName | (string & {}) = '';
value: number | undefined = undefined;
disabled = false;
target: 'player' | 'document' = 'player';
readonly #player = new PlayerController(this, playerContext);
readonly #container = new ContextConsumer(this, { context: containerContext, subscribe: true });
#cleanup: (() => 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;
}
}
@@ -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 {}
}
@@ -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<Element extends HTMLElement>(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();
});
});
+13 -1
View File
@@ -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<Core extends MediaButtonComponent> exte
protected abstract activate(state: InferMediaState<Core>): void;
/** Override to set the hotkey action name for `aria-keyshortcuts`. */
protected readonly hotkeyAction: string | undefined = undefined;
get $state(): State<ButtonState> {
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<Core extends MediaButtonComponent> 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);
}
}
@@ -11,6 +11,7 @@ export class MuteButtonElement extends MediaButtonElement<MuteButtonCore> {
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);
@@ -11,6 +11,7 @@ export class PiPButtonElement extends MediaButtonElement<PiPButtonCore> {
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);
@@ -11,6 +11,7 @@ export class PlayButtonElement extends MediaButtonElement<PlayButtonCore> {
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);