mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
feat(packages): add hotkey system with coordinator, actions, and ARIA support (#1238)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isMacOS(): boolean {
|
||||
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user