feat(avocet): useLabelKeyboard — 1-9, h, S, D, U, ? shortcuts

This commit is contained in:
pyr0ball 2026-03-03 16:12:58 -08:00
parent cd6cae2040
commit 5114e6ac19
2 changed files with 132 additions and 0 deletions

View 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()
})
})

View 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 }
}