mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add hotkeys (#1239)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user