Scoped Key Bindings
Attach keyboard shortcuts to specific DOM elements instead of the entire document.
Global bindings (attached to document) work well for app-wide shortcuts like Ctrl+K. But some shortcuts should only work when a specific element is focused.
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>
)
}The target element must be focusable to receive keyboard events. Add tabIndex={0} if it's not natively focusable (like a 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
When a scoped binding fires, it calls event.stopPropagation() if that option is set. This prevents parent handlers from also firing. Use this to create nested shortcut contexts.
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())