Skip to main content

Lesson 8: Advanced Patterns

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

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

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 registered
registry.register('ctrl+k', command, { conflictBehavior: 'error' })
// Throws if ctrl+k is already registered

The requireReset pattern

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 const

Use ignoreInputs for character bindings

Always preventDefault for browser shortcuts

Ctrl+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')

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

  1. Why keyboard shortcuts matter and how duck-vim's registry pattern solves common problems.
  2. How to create a Registry, register commands, and attach a KeyHandler.
  3. How key binding strings are parsed, normalized, and validated.
  4. How to integrate with React using KeyProvider and hooks.
  5. How multi-key sequences work with both the Registry and SequenceManager.
  6. How to format shortcuts for display with platform-aware labels.
  7. How to build a shortcut customization UI with the KeyRecorder.
  8. Advanced patterns: scoping, command palettes, conflict detection, and testing.

You now have everything you need to build a professional keyboard-driven interface.