Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"shx": "^0.3.4",
"storybook": "^8.5.2",
"ts-node": "^10.9.1",
"typescript": "^5.0.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5",
"wait-on": "^7.1.0"
},
Expand Down
16 changes: 10 additions & 6 deletions src/hooks/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/unified-signatures, @typescript-eslint/no-explicit-any */
import { useEffect, type RefObject } from 'react';
import { unref } from '../../utils/unref/unref.js';
import { useEffect } from 'react';
import { unref, type Unreffable } from '../../utils/unref/unref.js';
import { useRefValue } from '../useRefValue/useRefValue.js';

export function useEventListener<T extends EventTarget>(
targetOption: RefObject<T> | T | null | undefined,
export function useEventListener<T extends EventTarget | null>(
targetOption: Unreffable<T> | undefined,
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions,
Expand All @@ -14,14 +14,18 @@ export function useEventListener<T extends EventTarget>(
useEffect(() => {
const target = unref(targetOption);

if (!target) {
return;
}

function callback(this: unknown, event: Event): void {
listenerRef.current?.(event);
}

target?.addEventListener(type, callback, options);
target.addEventListener(type, callback, options);

return () => {
target?.removeEventListener(type, callback, options);
target.removeEventListener(type, callback, options);
};
}, [listenerRef, options, targetOption, type]);
}
6 changes: 6 additions & 0 deletions src/hooks/useKeyboardShortcut/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type { UseKeyboardShortcutOptions } from './useKeyboardShortcut.js';
export { useKeyboardShortcut } from './useKeyboardShortcut.js';

export type { Command, Shortcut, SimpleShortcut, Modifier } from './useKeyboardShortcut.types.js';
export { keyCodes, modifierKeys } from './keyCodes.js';
export type { KeyCode, ModifierKeyCode } from './keyCodes.js';
119 changes: 119 additions & 0 deletions src/hooks/useKeyboardShortcut/isShortcutDown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { isShortcutDown } from './isShortcutDown.js';
import type { Command } from './useKeyboardShortcut.types.js';

describe('isShortcutDown', () => {
it('should return true when key and modifiers match exactly', () => {
const event = new KeyboardEvent('keydown', {
code: 'KeyA',
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
});

const command: Command = {
code: 'KeyA',
ctrlKey: true,
};

expect(isShortcutDown(event, command)).toBe(true);
});

it('should return false when key matches but modifiers do not', () => {
const event = new KeyboardEvent('keydown', {
code: 'KeyA',
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
});

const command: Command = {
code: 'KeyA',
ctrlKey: true,
};

expect(isShortcutDown(event, command)).toBe(false);
});

it('should return false when modifiers match but key does not', () => {
const event = new KeyboardEvent('keydown', {
code: 'KeyB',
ctrlKey: true,
});

const command: Command = {
code: 'KeyA',
ctrlKey: true,
};

expect(isShortcutDown(event, command)).toBe(false);
});

it('should be case-insensitive for key matching with the shift key', () => {
const event = new KeyboardEvent('keydown', {
code: 'KeyA',
ctrlKey: false,
shiftKey: true,
altKey: false,
metaKey: false,
});

const command: Command = {
code: 'KeyA',
shiftKey: true,
};

expect(isShortcutDown(event, command)).toBe(true);
});

it('should handle multiple modifiers correctly', () => {
const event = new KeyboardEvent('keydown', {
code: 'KeyA',
ctrlKey: true,
shiftKey: true,
altKey: true,
metaKey: true,
});

const command: Command = {
code: 'KeyA',
ctrlKey: true,
shiftKey: true,
altKey: true,
metaKey: true,
};

expect(isShortcutDown(event, command)).toBe(true);
});

it('should match alt+key even when it produces a special character', () => {
// On macOS, alt+n produces ñ, but we want to match 'n'
const event = new KeyboardEvent('keydown', {
code: 'KeyN',
altKey: true,
});

const command: Command = {
code: 'KeyN',
altKey: true,
};

expect(isShortcutDown(event, command)).toBe(true);
});

it('should handle single modifier keys on either side', () => {
const event = new KeyboardEvent('keydown', {
code: 'ControlRight',
ctrlKey: true,
});

const command: Command = {
code: 'ControlLeft',
ctrlKey: true,
};

expect(isShortcutDown(event, command)).toBe(true);
});
});
50 changes: 50 additions & 0 deletions src/hooks/useKeyboardShortcut/isShortcutDown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { isModifierKeyCode } from './parseShortcut.js';
import type { Command } from './useKeyboardShortcut.types.js';

const modifiers = ['ctrl', 'shift', 'alt', 'meta'] as const;

/**
* Checks if the provided event matches the command.
* - if they key code matches
* - if all modifiers match, no more and no less
*
* @param event The keyboard event to check.
* @param command The command to match against.
* @returns True if the event matches the command, false otherwise.
*/
export function isShortcutDown(event: KeyboardEvent, command: Command): boolean {
// Check that only the required modifiers are down
const areModifiersCorrect = modifiers.every(
(modifier) => event[`${modifier}Key`] === Boolean(command[`${modifier}Key`]),
);
if (!areModifiersCorrect) {
return false;
}

// if we are only testing for modifiers, we are done
if (isModifierKeyCode(command.code)) {
return true;
}

// Check if the pressed key matches the command key
const isKeyDown = event.code === command.code;

// TODO: Add a mode where it checks against the `key` instead of `code`,
// to allow for better support for different keyboard layouts,
// where the same code might input a different character
// However, this would only work with specific modifier:
//
// Shift will do capitals or other characters:
// - shift+/ = ?, so '?' is the shortcut to register in that mode
//
// Alt/Option will do special characters:
// - Alt+/ = ÷, so '÷' is the shortcut to register in that mode
// - Alt+n = Dead Key for a ~ character above the next key, so cannot be used as a shortcut that way.
//
// Ctrl/Cmd should be fine, as they don't change the character that would be typed.
//
// Likely the `Command` type should be updated to add a `key` field, and both `key` and `code` should be optional.
// Unless we can make one required based on the `mode` configuration.

return isKeyDown;
}
Loading