Skip to main content

Scoped Key Bindings

Attach keyboard shortcuts to specific DOM elements instead of the entire document.

Why scope bindings?

Global bindings work well for app-wide shortcuts like Ctrl+K for a command palette. But some shortcuts should only work when a specific element is focused, like Ctrl+S in an editor panel or arrow keys in a list.

duck-vim supports scoped bindings in both vanilla and React usage.


Vanilla: Scoped KeyHandler

Create a separate KeyHandler and attach it to a specific element:

import { Registry, KeyHandler } from '@gentleduck/vim/command'
 
const registry = new Registry()
const editorHandler = new KeyHandler(registry, 600)
 
registry.register('ctrl+s', {
  name: 'Save',
  execute: () => saveDocument(),
}, { preventDefault: true })
 
// Only listens on the editor element
const editor = document.getElementById('editor')!
editorHandler.attach(editor)
import { Registry, KeyHandler } from '@gentleduck/vim/command'
 
const registry = new Registry()
const editorHandler = new KeyHandler(registry, 600)
 
registry.register('ctrl+s', {
  name: 'Save',
  execute: () => saveDocument(),
}, { preventDefault: true })
 
// Only listens on the editor element
const editor = document.getElementById('editor')!
editorHandler.attach(editor)

The binding only fires when keydown events originate from within the editor element.


Vanilla: Standalone handler

For a quick one-off, use createKeyBindHandler and attach to any element:

import { createKeyBindHandler } from '@gentleduck/vim/matcher'
 
const handler = createKeyBindHandler({
  binding: 'ctrl+enter',
  handler: () => submitForm(),
  options: { preventDefault: true },
})
 
document.getElementById('form')!.addEventListener('keydown', handler)
import { createKeyBindHandler } from '@gentleduck/vim/matcher'
 
const handler = createKeyBindHandler({
  binding: 'ctrl+enter',
  handler: () => submitForm(),
  options: { preventDefault: true },
})
 
document.getElementById('form')!.addEventListener('keydown', handler)

React: targetRef option

Both useKeyBind and useKeySequence accept a targetRef option:

function Editor() {
  const editorRef = useRef<HTMLDivElement>(null)
 
  useKeyBind('ctrl+s', () => save(), {
    preventDefault: true,
    targetRef: editorRef,
  })
 
  useKeyBind('ctrl+z', () => undo(), {
    preventDefault: true,
    targetRef: editorRef,
  })
 
  return (
    <div ref={editorRef} tabIndex={0} className="editor">
      {/* Editor content */}
    </div>
  )
}
function Editor() {
  const editorRef = useRef<HTMLDivElement>(null)
 
  useKeyBind('ctrl+s', () => save(), {
    preventDefault: true,
    targetRef: editorRef,
  })
 
  useKeyBind('ctrl+z', () => undo(), {
    preventDefault: true,
    targetRef: editorRef,
  })
 
  return (
    <div ref={editorRef} tabIndex={0} className="editor">
      {/* Editor content */}
    </div>
  )
}

Multiple scopes

You can create isolated shortcut systems by using separate registries or by leveraging useKeyBind with different targetRef values:

function App() {
  const sidebarRef = useRef<HTMLDivElement>(null)
  const mainRef = useRef<HTMLDivElement>(null)
 
  // Only fires in sidebar
  useKeyBind('j', () => nextItem(), { targetRef: sidebarRef })
  useKeyBind('k', () => prevItem(), { targetRef: sidebarRef })
 
  // Only fires in main area
  useKeyBind('e', () => editSelected(), { targetRef: mainRef })
 
  // Global (no targetRef)
  useKeyBind('ctrl+k', () => openPalette(), { preventDefault: true })
 
  return (
    <div className="flex">
      <div ref={sidebarRef} tabIndex={0}>Sidebar</div>
      <div ref={mainRef} tabIndex={0}>Main</div>
    </div>
  )
}
function App() {
  const sidebarRef = useRef<HTMLDivElement>(null)
  const mainRef = useRef<HTMLDivElement>(null)
 
  // Only fires in sidebar
  useKeyBind('j', () => nextItem(), { targetRef: sidebarRef })
  useKeyBind('k', () => prevItem(), { targetRef: sidebarRef })
 
  // Only fires in main area
  useKeyBind('e', () => editSelected(), { targetRef: mainRef })
 
  // Global (no targetRef)
  useKeyBind('ctrl+k', () => openPalette(), { preventDefault: true })
 
  return (
    <div className="flex">
      <div ref={sidebarRef} tabIndex={0}>Sidebar</div>
      <div ref={mainRef} tabIndex={0}>Main</div>
    </div>
  )
}

Nested scopes

useKeyBind('escape', () => closeModal(), {
  targetRef: modalRef,
  stopPropagation: true,
})
 
// This won't fire when Escape is pressed inside the modal
useKeyBind('escape', () => closeSidebar())
useKeyBind('escape', () => closeModal(), {
  targetRef: modalRef,
  stopPropagation: true,
})
 
// This won't fire when Escape is pressed inside the modal
useKeyBind('escape', () => closeSidebar())