Lesson 8: Advanced Patterns
Scoped bindings, command palettes, conflict detection, testing, and production best practices.
Lesson 8 of 8 — Scoped bindings, command palettes, conflict detection, testing, and production best practices.
Scoped bindings
Not every shortcut should be global. Some should only work when a specific element is focused.
const editorRegistry = new Registry()
const editorHandler = new KeyHandler(editorRegistry, 600)
editorRegistry.register('ctrl+s', saveCommand, { preventDefault: true })
editorRegistry.register('ctrl+z', undoCommand, { preventDefault: true })
const editorEl = document.getElementById('editor')!
editorHandler.attach(editorEl)const editorRegistry = new Registry()
const editorHandler = new KeyHandler(editorRegistry, 600)
editorRegistry.register('ctrl+s', saveCommand, { preventDefault: true })
editorRegistry.register('ctrl+z', undoCommand, { preventDefault: true })
const editorEl = document.getElementById('editor')!
editorHandler.attach(editorEl)Nested scopes
Use stopPropagation: true to prevent shortcuts from bubbling up:
// Modal catches Escape first
useKeyBind('escape', () => closeModal(), {
targetRef: modalRef,
stopPropagation: true,
})
// App-level Escape won't fire when modal is open
useKeyBind('escape', () => closeSidebar())// Modal catches Escape first
useKeyBind('escape', () => closeModal(), {
targetRef: modalRef,
stopPropagation: true,
})
// App-level Escape won't fire when modal is open
useKeyBind('escape', () => closeSidebar())Building a command palette
Since the Registry stores names and descriptions, building a command palette is straightforward.
function CommandPalette() {
const ctx = useContext(KeyContext)
const [query, setQuery] = useState('')
const items = useMemo(() => {
if (!ctx) return []
const all = Array.from(ctx.registry.getAllCommands())
if (!query) return all
const q = query.toLowerCase()
return all.filter(([, cmd]) => cmd.name.toLowerCase().includes(q))
}, [ctx, query])
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search commands..."
autoFocus
/>
{items.map(([binding, cmd]) => (
<button key={binding} onClick={() => cmd.execute()}>
{cmd.name}
<kbd>{formatForDisplay(binding)}</kbd>
</button>
))}
</div>
)
}function CommandPalette() {
const ctx = useContext(KeyContext)
const [query, setQuery] = useState('')
const items = useMemo(() => {
if (!ctx) return []
const all = Array.from(ctx.registry.getAllCommands())
if (!query) return all
const q = query.toLowerCase()
return all.filter(([, cmd]) => cmd.name.toLowerCase().includes(q))
}, [ctx, query])
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search commands..."
autoFocus
/>
{items.map(([binding, cmd]) => (
<button key={binding} onClick={() => cmd.execute()}>
{cmd.name}
<kbd>{formatForDisplay(binding)}</kbd>
</button>
))}
</div>
)
}See the Command Palette guide for a more complete implementation.
Conflict detection
When users customize shortcuts or when you register many bindings, conflicts can occur. Detect them early.
import { normalizeKeyBind } from '@gentleduck/vim/parser'
function findConflicts(registry: Registry): Array<[string, string]> {
const normalized = new Map<string, string[]>()
for (const [binding] of registry.getAllCommands()) {
const norm = normalizeKeyBind(binding)
const existing = normalized.get(norm) ?? []
existing.push(binding)
normalized.set(norm, existing)
}
const conflicts: Array<[string, string]> = []
for (const [, bindings] of normalized) {
if (bindings.length > 1) {
conflicts.push([bindings[0]!, bindings[1]!])
}
}
return conflicts
}import { normalizeKeyBind } from '@gentleduck/vim/parser'
function findConflicts(registry: Registry): Array<[string, string]> {
const normalized = new Map<string, string[]>()
for (const [binding] of registry.getAllCommands()) {
const norm = normalizeKeyBind(binding)
const existing = normalized.get(norm) ?? []
existing.push(binding)
normalized.set(norm, existing)
}
const conflicts: Array<[string, string]> = []
for (const [, bindings] of normalized) {
if (bindings.length > 1) {
conflicts.push([bindings[0]!, bindings[1]!])
}
}
return conflicts
}Or use the conflictBehavior option during registration:
registry.register('ctrl+k', command, { conflictBehavior: 'error' })
// Throws if ctrl+k is already registeredregistry.register('ctrl+k', command, { conflictBehavior: 'error' })
// Throws if ctrl+k is already registeredThe requireReset pattern
For one-shot actions like "Save", you might want the shortcut to fire only once until explicitly reset (to prevent double-saves from held keys).
const handle = registry.register('ctrl+s', {
name: 'Save',
execute: async () => {
await saveDocument()
handle.resetFired() // Allow firing again
},
}, { requireReset: true, preventDefault: true })const handle = registry.register('ctrl+s', {
name: 'Save',
execute: async () => {
await saveDocument()
handle.resetFired() // Allow firing again
},
}, { requireReset: true, preventDefault: true })Without requireReset, holding Ctrl+S would trigger save() on every keydown repeat event.
Testing
Unit testing key parsing
import { parseKeyBind, normalizeKeyBind, validateKeyBind } from '@gentleduck/vim/parser'
test('parses Mod+S on Mac', () => {
const result = parseKeyBind('Mod+S', 'mac')
expect(result.meta).toBe(true)
expect(result.ctrl).toBe(false)
expect(result.key).toBe('s')
})
test('normalizes binding order', () => {
expect(normalizeKeyBind('Shift+Ctrl+K')).toBe('ctrl+shift+k')
})
test('validates empty binding', () => {
const result = validateKeyBind('')
expect(result.valid).toBe(false)
})import { parseKeyBind, normalizeKeyBind, validateKeyBind } from '@gentleduck/vim/parser'
test('parses Mod+S on Mac', () => {
const result = parseKeyBind('Mod+S', 'mac')
expect(result.meta).toBe(true)
expect(result.ctrl).toBe(false)
expect(result.key).toBe('s')
})
test('normalizes binding order', () => {
expect(normalizeKeyBind('Shift+Ctrl+K')).toBe('ctrl+shift+k')
})
test('validates empty binding', () => {
const result = validateKeyBind('')
expect(result.valid).toBe(false)
})Unit testing matching
import { matchesKeyboardEvent } from '@gentleduck/vim/matcher'
import { parseKeyBind } from '@gentleduck/vim/parser'
test('matches ctrl+k event', () => {
const parsed = parseKeyBind('ctrl+k')
const event = new KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
})
expect(matchesKeyboardEvent(parsed, event)).toBe(true)
})
test('does not match without ctrl', () => {
const parsed = parseKeyBind('ctrl+k')
const event = new KeyboardEvent('keydown', { key: 'k' })
expect(matchesKeyboardEvent(parsed, event)).toBe(false)
})import { matchesKeyboardEvent } from '@gentleduck/vim/matcher'
import { parseKeyBind } from '@gentleduck/vim/parser'
test('matches ctrl+k event', () => {
const parsed = parseKeyBind('ctrl+k')
const event = new KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
})
expect(matchesKeyboardEvent(parsed, event)).toBe(true)
})
test('does not match without ctrl', () => {
const parsed = parseKeyBind('ctrl+k')
const event = new KeyboardEvent('keydown', { key: 'k' })
expect(matchesKeyboardEvent(parsed, event)).toBe(false)
})Integration testing the Registry
import { Registry, KeyHandler } from '@gentleduck/vim/command'
test('executes command on key match', () => {
const registry = new Registry()
const handler = new KeyHandler(registry)
const fn = vi.fn()
registry.register('ctrl+k', { name: 'test', execute: fn })
handler.attach(document)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
)
expect(fn).toHaveBeenCalledOnce()
handler.detach(document)
})import { Registry, KeyHandler } from '@gentleduck/vim/command'
test('executes command on key match', () => {
const registry = new Registry()
const handler = new KeyHandler(registry)
const fn = vi.fn()
registry.register('ctrl+k', { name: 'test', execute: fn })
handler.attach(document)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
)
expect(fn).toHaveBeenCalledOnce()
handler.detach(document)
})Testing platform detection
import { _resetPlatformCache } from '@gentleduck/vim/platform'
beforeEach(() => {
_resetPlatformCache()
})import { _resetPlatformCache } from '@gentleduck/vim/platform'
beforeEach(() => {
_resetPlatformCache()
})Production best practices
Centralize registrations
Define all shortcuts in one place for discoverability:
// shortcuts.ts
export const APP_SHORTCUTS = {
'Mod+k': { name: 'Command Palette', execute: openPalette },
'Mod+s': { name: 'Save', execute: save },
'g+d': { name: 'Go Dashboard', execute: () => navigate('/dashboard') },
'g+s': { name: 'Go Settings', execute: () => navigate('/settings') },
} as const// shortcuts.ts
export const APP_SHORTCUTS = {
'Mod+k': { name: 'Command Palette', execute: openPalette },
'Mod+s': { name: 'Save', execute: save },
'g+d': { name: 'Go Dashboard', execute: () => navigate('/dashboard') },
'g+s': { name: 'Go Settings', execute: () => navigate('/settings') },
} as constUse ignoreInputs for character bindings
Any binding that doesn't use a modifier (like G, D, /) should have ignoreInputs: true. Otherwise it fires when the user is typing.
ignoreInputs for character bindingsAny binding that doesn't use a modifier (like G, D, /) should have ignoreInputs: true. Otherwise it fires when the user is typing.
Always preventDefault for browser shortcuts
preventDefault for browser shortcutsCtrl+S, Ctrl+W, Ctrl+N, Ctrl+P, Ctrl+T all have browser defaults. Set preventDefault: true or the browser action fires alongside your command.
Debug in development only
const registry = new Registry(process.env.NODE_ENV === 'development')
const registry = new Registry(process.env.NODE_ENV === 'development')
const registry = new Registry(process.env.NODE_ENV === 'development')const registry = new Registry(process.env.NODE_ENV === 'development')Clean up on unmount
Every register() call should have a corresponding unregister(). The React hooks handle this automatically. In vanilla code, track your handles.
Test cross-platform
Use the platform parameter in parseKeyBind and formatForDisplay to test all three platforms without needing different machines:
for (const platform of ['mac', 'windows', 'linux'] as const) {
const result = parseKeyBind('Mod+S', platform)
console.log(platform, result)
}for (const platform of ['mac', 'windows', 'linux'] as const) {
const result = parseKeyBind('Mod+S', platform)
console.log(platform, result)
}What you've learned
Over these 8 lessons, you've learned everything you need to build a professional keyboard-driven interface.
- Why keyboard shortcuts matter and how duck-vim's registry pattern solves common problems.
- How to create a Registry, register commands, and attach a KeyHandler.
- How key binding strings are parsed, normalized, and validated.
- How to integrate with React using KeyProvider and hooks.
- How multi-key sequences work with both the Registry and SequenceManager.
- How to format shortcuts for display with platform-aware labels.
- How to build a shortcut customization UI with the KeyRecorder.
- Advanced patterns: scoping, command palettes, conflict detection, and testing.
You now have everything you need to build a professional keyboard-driven interface.