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
154 changes: 56 additions & 98 deletions packages/0/src/composables/useKeydown/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,131 +1,89 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useKeydown } from './index'

// Mock Vue's lifecycle hooks
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: vi.fn(),
getCurrentScope: vi.fn(() => true),
onScopeDispose: vi.fn(),
}
})
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useKeydown, handlerMap } from './index'

describe('useKeydown', () => {
let addEventListenerSpy: ReturnType<typeof vi.spyOn>
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
// Mock document.addEventListener and removeEventListener
vi.spyOn(document, 'addEventListener')
vi.spyOn(document, 'removeEventListener')
addEventListenerSpy = vi.spyOn(document, 'addEventListener')
removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
})

afterEach(() => {
vi.restoreAllMocks()
handlerMap.clear()
})

describe('basic functionality', () => {
it('should return startListening and stopListening functions', () => {
const handler = { key: 'Enter', handler: vi.fn() }
const result = useKeydown(handler)

expect(result).toHaveProperty('startListening')
expect(result).toHaveProperty('stopListening')
expect(typeof result.startListening).toBe('function')
expect(typeof result.stopListening).toBe('function')
})

it('should accept single handler', () => {
const handler = { key: 'Enter', handler: vi.fn() }

expect(() => useKeydown(handler)).not.toThrow()
})

it('should accept array of handlers', () => {
const handlers = [
{ key: 'Enter', handler: vi.fn() },
{ key: 'Escape', handler: vi.fn() },
]

expect(() => useKeydown(handlers)).not.toThrow()
})
})

describe('event handling', () => {
it('should call handler when matching key is pressed', () => {
const mockHandler = vi.fn()
const handler = { key: 'Enter', handler: mockHandler }
const { startListening } = useKeydown(handler)
it('should create only one global listener for multiple useKeydown calls', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
const handler3 = vi.fn()

startListening()
const keydown1 = useKeydown({ key: 'Enter', handler: handler1 })
const keydown2 = useKeydown({ key: 'Escape', handler: handler2 })
const keydown3 = useKeydown({ key: 'Space', handler: handler3 })

// Simulate keydown event
const event = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(event)
keydown1.startListening()
keydown2.startListening()
keydown3.startListening()

expect(mockHandler).toHaveBeenCalledWith(event)
})
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))

it('should not call handler when non-matching key is pressed', () => {
const mockHandler = vi.fn()
const handler = { key: 'Enter', handler: mockHandler }
const { startListening } = useKeydown(handler)
expect(handlerMap.size).toBe(3)

startListening()
keydown1.stopListening()
keydown2.stopListening()
keydown3.stopListening()

// Simulate keydown event with different key
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
expect(handlerMap.size).toBe(0)
})

expect(mockHandler).not.toHaveBeenCalled()
})
it('should handle multiple handlers for the same key', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()

it('should prevent default when preventDefault is true', () => {
const mockHandler = vi.fn()
const handler = { key: 'Enter', handler: mockHandler, preventDefault: true }
const { startListening } = useKeydown(handler)
const keydown1 = useKeydown({ key: 'Enter', handler: handler1 })
const keydown2 = useKeydown({ key: 'Enter', handler: handler2 })

startListening()
keydown1.startListening()
keydown2.startListening()

const event = new KeyboardEvent('keydown', { key: 'Enter' })
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
document.dispatchEvent(event)
const event = { key: 'Enter' } as KeyboardEvent

expect(preventDefaultSpy).toHaveBeenCalled()
})
const registeredHandler = addEventListenerSpy.mock.calls[0][1] as (event: KeyboardEvent) => void
registeredHandler(event)

it('should stop propagation when stopPropagation is true', () => {
const mockHandler = vi.fn()
const handler = { key: 'Enter', handler: mockHandler, stopPropagation: true }
const { startListening } = useKeydown(handler)
expect(handler1).toHaveBeenCalledWith(event)
expect(handler2).toHaveBeenCalledWith(event)

startListening()
keydown1.stopListening()
keydown2.stopListening()
})

const event = new KeyboardEvent('keydown', { key: 'Enter' })
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
document.dispatchEvent(event)
it('should not remove global listener if other handlers are still active', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()

expect(stopPropagationSpy).toHaveBeenCalled()
})
})
const keydown1 = useKeydown({ key: 'Enter', handler: handler1 })
const keydown2 = useKeydown({ key: 'Escape', handler: handler2 })

describe('lifecycle management', () => {
it('should add event listener when startListening is called', () => {
const handler = { key: 'Enter', handler: vi.fn() }
const { startListening } = useKeydown(handler)
keydown1.startListening()
keydown2.startListening()

startListening()
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)

expect(document.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function))
})
keydown1.stopListening()

it('should remove event listener when stopListening is called', () => {
const handler = { key: 'Enter', handler: vi.fn() }
const { stopListening } = useKeydown(handler)
expect(removeEventListenerSpy).not.toHaveBeenCalled()
expect(handlerMap.size).toBe(1)

stopListening()
keydown2.stopListening()

expect(document.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function))
})
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
expect(handlerMap.size).toBe(0)
})
})
86 changes: 71 additions & 15 deletions packages/0/src/composables/useKeydown/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Utilities
import { onMounted, getCurrentScope, onScopeDispose } from 'vue'
import { onMounted, getCurrentScope, onScopeDispose, ref, shallowReadonly } from 'vue'
import type { ID } from '#v0/types'
import { genId } from '#v0/utilities'

export interface KeyHandler {
key: string
Expand All @@ -8,6 +9,39 @@ export interface KeyHandler {
stopPropagation?: boolean
}

export interface UseKeydownOptions {
immediate?: boolean
}

let globalListener: ((event: KeyboardEvent) => void) | null = null
const handlerMap: Map<ID, KeyHandler> = new Map()

function startGlobalListener () {
if (typeof document === 'undefined') return

if (!globalListener) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whenever if { ... } guards most of the logic within a function it should be replaced with early exit if (!..) return, so we avoid unnecessary indentation and the inner logic is easier to read/scan-through.

There are 3 methods here that would benefit from this quick refactoring.

globalListener = (event: KeyboardEvent) => {
for (const h of handlerMap.values()) {
if (h.key === event.key) {
if (h.preventDefault) event.preventDefault()
if (h.stopPropagation) event.stopPropagation()
h.handler(event)
}
}
}
document.addEventListener('keydown', globalListener)
}
}

function stopGlobalListener () {
if (typeof document === 'undefined') return

if (globalListener && handlerMap.size === 0) {
document.removeEventListener('keydown', globalListener)
globalListener = null
}
}

/**
* Sets up global keyboard event listeners for specified key handlers with automatic cleanup.
* This composable automatically starts listening when mounted and cleans up when the scope
Expand All @@ -16,27 +50,46 @@ export interface KeyHandler {
* @param handlers A single handler or array of handlers to register for keydown events.
* @returns Object with methods to manually start and stop listening for keydown events.
*/
export function useKeydown (handlers: KeyHandler[] | KeyHandler) {
const keyHandlers = Array.isArray(handlers) ? handlers : [handlers]

function onKeydown (event: KeyboardEvent) {
const handler = keyHandlers.find(h => h.key === event.key)
if (handler) {
if (handler.preventDefault) event.preventDefault()
if (handler.stopPropagation) event.stopPropagation()
handler.handler(event)
}
}
export function useKeydown (handlers: KeyHandler[] | KeyHandler, options: UseKeydownOptions = {}) {
const { immediate = true } = options
const keyHandlers = Array.isArray(handlers) ? handlers : [handlers]
const handlerIds = ref<ID[]>([])
const isListening = ref(false)

function startListening () {
document.addEventListener('keydown', onKeydown)
if (!isListening.value) {
const ids = Array.from({ length: keyHandlers.length }, genId)

for (const [index, id] of ids.entries()) {
handlerMap.set(id, keyHandlers[index])
}

handlerIds.value = ids

if (handlerMap.size > 0) {
startGlobalListener()
}

isListening.value = true
}
}

function stopListening () {
document.removeEventListener('keydown', onKeydown)
if (isListening.value) {
for (const id of handlerIds.value) {
handlerMap.delete(id)
}
handlerIds.value = []
isListening.value = false

if (handlerMap.size === 0) {
stopGlobalListener()
}
}
}

if (getCurrentScope()) {
if (getCurrentScope() && immediate) {
onMounted(startListening)
}

Expand All @@ -45,5 +98,8 @@ export function useKeydown (handlers: KeyHandler[] | KeyHandler) {
return {
startListening,
stopListening,
isListening: shallowReadonly(isListening),
}
}

export { handlerMap }
Loading