feat(packages): add hotkey system with coordinator, actions, and ARIA support (#1238)

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