feat(avocet): useLabelKeyboard — 1-9, h, S, D, U, ? shortcuts
This commit is contained in:
parent
cd6cae2040
commit
5114e6ac19
2 changed files with 132 additions and 0 deletions
92
web/src/composables/useLabelKeyboard.test.ts
Normal file
92
web/src/composables/useLabelKeyboard.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useLabelKeyboard } from './useLabelKeyboard'
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
const LABELS = [
|
||||||
|
{ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' },
|
||||||
|
{ name: 'offer_received', key: '2', emoji: '🎉', color: '#2196F3' },
|
||||||
|
{ name: 'rejected', key: '3', emoji: '❌', color: '#F44336' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('useLabelKeyboard', () => {
|
||||||
|
const cleanups: (() => void)[] = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanups.forEach(fn => fn())
|
||||||
|
cleanups.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onLabel when digit key pressed', () => {
|
||||||
|
const onLabel = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
|
||||||
|
expect(onLabel).toHaveBeenCalledWith('interview_scheduled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onLabel for key 2', () => {
|
||||||
|
const onLabel = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: '2' }))
|
||||||
|
expect(onLabel).toHaveBeenCalledWith('offer_received')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onLabel("hired") when h pressed', () => {
|
||||||
|
const onLabel = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
|
||||||
|
expect(onLabel).toHaveBeenCalledWith('hired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSkip when s pressed', () => {
|
||||||
|
const onSkip = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip, onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
|
||||||
|
expect(onSkip).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDiscard when d pressed', () => {
|
||||||
|
const onDiscard = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard, onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||||
|
expect(onDiscard).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onUndo when u pressed', () => {
|
||||||
|
const onUndo = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo, onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'u' }))
|
||||||
|
expect(onUndo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onHelp when ? pressed', () => {
|
||||||
|
const onHelp = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }))
|
||||||
|
expect(onHelp).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores keydown when target is an input', () => {
|
||||||
|
const onLabel = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanups.push(cleanup)
|
||||||
|
const input = document.createElement('input')
|
||||||
|
document.body.appendChild(input)
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
|
||||||
|
expect(onLabel).not.toHaveBeenCalled()
|
||||||
|
document.body.removeChild(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleanup removes the listener', () => {
|
||||||
|
const onLabel = vi.fn()
|
||||||
|
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||||
|
cleanup()
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
|
||||||
|
expect(onLabel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
40
web/src/composables/useLabelKeyboard.ts
Normal file
40
web/src/composables/useLabelKeyboard.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { onUnmounted, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
interface Label { name: string; key: string; emoji: string; color: string }
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
labels: Label[]
|
||||||
|
onLabel: (name: string) => void
|
||||||
|
onSkip: () => void
|
||||||
|
onDiscard: () => void
|
||||||
|
onUndo: () => void
|
||||||
|
onHelp: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabelKeyboard(opts: Options) {
|
||||||
|
const keyMap = new Map(opts.labels.map(l => [l.key.toLowerCase(), l.name]))
|
||||||
|
|
||||||
|
function handler(e: KeyboardEvent) {
|
||||||
|
if (e.target instanceof HTMLInputElement) return
|
||||||
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const k = e.key.toLowerCase()
|
||||||
|
if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return }
|
||||||
|
if (k === 'h') { opts.onLabel('hired'); return }
|
||||||
|
if (k === 's') { opts.onSkip(); return }
|
||||||
|
if (k === 'd') { opts.onDiscard(); return }
|
||||||
|
if (k === 'u') { opts.onUndo(); return }
|
||||||
|
if (k === '?') { opts.onHelp(); return }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener immediately (composable is called in setup, not in onMounted)
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
|
||||||
|
const cleanup = () => window.removeEventListener('keydown', handler)
|
||||||
|
|
||||||
|
// In component context: auto-cleanup on unmount
|
||||||
|
if (getCurrentInstance()) {
|
||||||
|
onUnmounted(cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cleanup }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue