Skip to content
Open
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: 15 additions & 0 deletions packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
return keys.toLowerCase().split(delimiter)
}

/**
* Check if sequence ends with a given sub-sequence
* @param sequence full sequence e.g. ['h', 'e', 'l', 'l', 'o']
* @param subSequence sub-sequence to check e.g. ['l', 'l', 'o']
*/
export const sequenceEndsWith = (sequence: string[], subSequence: string[]): boolean => {
if (sequence.length < subSequence.length) {
return false
}

const endOfSequence = sequence.slice(-subSequence.length)

return subSequence.every((key, index) => key === endOfSequence[index])
}

export function parseHotkey(
hotkey: string,
splitKey = '+',
Expand Down
159 changes: 74 additions & 85 deletions packages/react-hotkeys-hook/src/lib/useHotkeys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types'
import type { Hotkey, HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types'
import { type DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier } from './parseHotkeys'
import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier, sequenceEndsWith } from './parseHotkeys'
import {
isHotkeyEnabled,
isHotkeyEnabledOnTag,
Expand Down Expand Up @@ -65,6 +65,35 @@ export default function useHotkeys<T extends HTMLElement>(
let recordedKeys: string[] = []
let sequenceTimer: NodeJS.Timeout | undefined

const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter)
.reduce<[Hotkey[], Hotkey[]]>((acc, key) => {
const [_comboHotkey, _sequenceHotkey] = acc

const splitKey = memoisedOptions?.splitKey ?? '+'
const sequenceSplitKey = memoisedOptions?.sequenceSplitKey ?? '>'

if (key.includes(splitKey) && key.includes(sequenceSplitKey)) {
console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`)
}

const hotkey = parseHotkey(
key,
splitKey,
sequenceSplitKey,
memoisedOptions?.useKey,
memoisedOptions?.description,
)

if (hotkey.isSequence) {
_sequenceHotkey.push(hotkey)
} else {
_comboHotkey.push(hotkey)
}
return [_comboHotkey, _sequenceHotkey]
}, [[], []])

const combinedHotkeys = [...comboHotkeys, ...sequenceHotkeys]

const listener = (e: KeyboardEvent, isKeyUp = false) => {
if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) {
return
Expand All @@ -89,86 +118,66 @@ export default function useHotkeys<T extends HTMLElement>(
return
}

parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) => {
if (key.includes(memoisedOptions?.splitKey ?? '+') && key.includes(memoisedOptions?.sequenceSplitKey ?? '>')) {
console.warn(
`Hotkey ${key} contains both ${memoisedOptions?.splitKey ?? '+'} and ${memoisedOptions?.sequenceSplitKey ?? '>'} which is not supported.`,
)
return
}
// ========== HANDLE COMBO HOTKEYS ==========
for (const hotkey of comboHotkeys) {
if (
isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) ||
hotkey.keys?.includes('*')
) {
if (memoisedOptions?.ignoreEventWhen?.(e)) {
continue
}

const hotkey = parseHotkey(
key,
memoisedOptions?.splitKey,
memoisedOptions?.sequenceSplitKey,
memoisedOptions?.useKey,
memoisedOptions?.description,
)
if (isKeyUp && hasTriggeredRef.current) {
continue
}

if (hotkey.isSequence) {
// Set a timeout to check post which the sequence should reset
sequenceTimer = setTimeout(() => {
recordedKeys = []
}, memoisedOptions?.sequenceTimeoutMs ?? 1000)
maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault)

const currentKey = hotkey.useKey ? e.key : mapCode(e.code)
if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) {
stopPropagation(e)

// TODO: Make modifiers work with sequences
if (isHotkeyModifier(currentKey.toLowerCase())) {
return
continue
}

recordedKeys.push(currentKey)
// Execute the user callback for that hotkey
cbRef.current(e, hotkey)

const expectedKey = hotkey.keys?.[recordedKeys.length - 1]
if (currentKey !== expectedKey) {
recordedKeys = []
if (sequenceTimer) {
clearTimeout(sequenceTimer)
}
return
if (!isKeyUp) {
hasTriggeredRef.current = true
}
}
}

// If the sequence is complete, trigger the callback
if (recordedKeys.length === hotkey.keys?.length) {
cbRef.current(e, hotkey)

if (sequenceTimer) {
clearTimeout(sequenceTimer)
}
// ========== HANDLE SEQUENCE HOTKEYS ==========
if (sequenceHotkeys.length > 0) {
const currentKey = memoisedOptions?.useKey
? e.key
: mapCode(e.code)

recordedKeys = []
if (!isHotkeyModifier(currentKey.toLowerCase())) {
// clear the previous timer
if (sequenceTimer) {
clearTimeout(sequenceTimer)
}
} else {
if (
isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) ||
hotkey.keys?.includes('*')
) {
if (memoisedOptions?.ignoreEventWhen?.(e)) {
return
}

if (isKeyUp && hasTriggeredRef.current) {
return
}

maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault)
sequenceTimer = setTimeout(() => {
recordedKeys = []
}, memoisedOptions?.sequenceTimeoutMs ?? 1000)

if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) {
stopPropagation(e)
recordedKeys.push(currentKey)

return
for (const hotkey of sequenceHotkeys) {
if (!hotkey.keys) {
continue
}

// Execute the user callback for that hotkey
cbRef.current(e, hotkey)

if (!isKeyUp) {
hasTriggeredRef.current = true
if (sequenceEndsWith(recordedKeys, [...hotkey.keys])) {
cbRef.current(e, hotkey)
}
}
}
})
}
}

const handleKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -207,17 +216,7 @@ export default function useHotkeys<T extends HTMLElement>(
domNode.addEventListener('keydown', handleKeyDown, _options?.eventListenerOptions)

if (proxy) {
parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
proxy.addHotkey(
parseHotkey(
key,
memoisedOptions?.splitKey,
memoisedOptions?.sequenceSplitKey,
memoisedOptions?.useKey,
memoisedOptions?.description,
),
),
)
combinedHotkeys.forEach(proxy.addHotkey)
}

return () => {
Expand All @@ -227,17 +226,7 @@ export default function useHotkeys<T extends HTMLElement>(
domNode.removeEventListener('keydown', handleKeyDown, _options?.eventListenerOptions)

if (proxy) {
parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
proxy.removeHotkey(
parseHotkey(
key,
memoisedOptions?.splitKey,
memoisedOptions?.sequenceSplitKey,
memoisedOptions?.useKey,
memoisedOptions?.description,
),
),
)
combinedHotkeys.forEach(proxy.removeHotkey)
}

recordedKeys = []
Expand Down
83 changes: 83 additions & 0 deletions packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,27 @@ test('should not trigger when sequence and combination are mixed', async () => {
expect(callback).not.toHaveBeenCalled()
})

test('should trigger both combination and sequence hotkeys when passed as array', async () => {
const user = userEvent.setup()
const callback = vi.fn()

renderHook(() => useHotkeys(['ctrl+a', 'y>e>e>t'], callback))

await user.keyboard('{Control>}A{/Control}')
expect(callback).toHaveBeenCalledTimes(1)
expect(callback.mock.calls[0][1].isSequence).toBe(false)

await user.keyboard('y')
vi.advanceTimersByTime(200)
await user.keyboard('e')
vi.advanceTimersByTime(200)
await user.keyboard('e')
vi.advanceTimersByTime(200)
await user.keyboard('t')
expect(callback).toHaveBeenCalledTimes(2)
expect(callback.mock.calls[1][1].isSequence).toBe(true)
})

test('should work with sequences and other hotkeys together', async () => {
const user = userEvent.setup()
const callback = vi.fn()
Expand Down Expand Up @@ -561,6 +582,68 @@ test('should not trigger sequence without useKey', async () => {
expect(callback).toHaveBeenCalledTimes(0)
})

test('should trigger callback for each sequence in array', async () => {
const user = userEvent.setup()
const callback = vi.fn()

renderHook(() => useHotkeys(['h>i', 'o>k'], callback))

await user.keyboard('h')
vi.advanceTimersByTime(200)
await user.keyboard('i')

expect(callback).toHaveBeenCalledTimes(1)

await user.keyboard('o')
vi.advanceTimersByTime(200)
await user.keyboard('k')

expect(callback).toHaveBeenCalledTimes(2)
})

test('should trigger callback for each overlapping sequence in array', async () => {
const user = userEvent.setup()
const callback = vi.fn()

renderHook(() => useHotkeys(['h>i', 'i>k'], callback))

await user.keyboard('h')
vi.advanceTimersByTime(200)
await user.keyboard('i')
expect(callback).toHaveBeenCalledTimes(1)

vi.advanceTimersByTime(200)
await user.keyboard('k')
expect(callback).toHaveBeenCalledTimes(2)
})

test('should trigger callback for overlapping substring sequences', async () => {
const user = userEvent.setup()
const callback = vi.fn()

renderHook(() => useHotkeys(['h>e>l>l>o', 'l>l>o'], callback))

await user.keyboard('h')
vi.advanceTimersByTime(100)
await user.keyboard('e')
vi.advanceTimersByTime(100)
await user.keyboard('l')
vi.advanceTimersByTime(100)
await user.keyboard('l')
vi.advanceTimersByTime(100)
await user.keyboard('o')

expect(callback).toHaveBeenCalledTimes(2)

vi.advanceTimersByTime(100)
await user.keyboard('l')
vi.advanceTimersByTime(100)
await user.keyboard('l')
vi.advanceTimersByTime(100)
await user.keyboard('o')
expect(callback).toHaveBeenCalledTimes(3)
})

test('should reflect set delimiter character', async () => {
const user = userEvent.setup()
const callback = vi.fn()
Expand Down