# Keyboard & Event Hooks
Comprehensive keyboard shortcut and event listener hooks for MetaBuilder applications.
## Quick Reference
| Hook | Purpose | Returns |
|------|---------|---------|
| **useKeyboardShortcuts** | Unified keyboard shortcut handling with meta keys | `{ registerShortcut, unregister, clearAll }` |
| **useClickOutside** | Detect clicks outside element | `{ ref, isOpen, setIsOpen, toggle }` |
| **useHotkeys** | Global hotkey registration with combo support | `{ register, unregister, unregisterAll }` |
| **useEventListener** | Generic event listener with cleanup | `{ add, remove, removeAll }` |
---
## useKeyboardShortcuts
Unified keyboard shortcut handling with automatic platform detection (macOS vs Windows/Linux).
### Features
- Register shortcuts with meta keys (cmd/ctrl, shift, alt)
- Platform detection (Mac vs Windows)
- Prevent default browser shortcuts
- Debounce support
- Clean unregistration
### Basic Usage
```typescript
import { useKeyboardShortcuts } from '@metabuilder/hooks'
function SaveDialog() {
const { registerShortcut, unregister } = useKeyboardShortcuts()
useEffect(() => {
// Register Ctrl+S (or Cmd+S on Mac)
const id = registerShortcut({
key: 's',
ctrl: true,
onPress: () => handleSave(),
preventDefault: true
})
return () => unregister(id)
}, [])
return
Save with Ctrl+S
}
```
### Meta Key Handling
```typescript
const { registerShortcut } = useKeyboardShortcuts()
// Automatic cross-platform:
// - On Mac: uses Cmd key
// - On Windows: uses Ctrl key
registerShortcut({
key: 's',
ctrl: true, // Triggers Cmd on Mac, Ctrl on Windows
onPress: save
})
// macOS-specific
registerShortcut({
key: 's',
cmd: true, // Only Cmd on Mac
onPress: save
})
```
### Complex Shortcuts
```typescript
const { registerShortcut } = useKeyboardShortcuts()
// Ctrl+Shift+Enter
registerShortcut({
key: 'Enter',
ctrl: true,
shift: true,
onPress: submitForm
})
// Ctrl+Alt+K (without preventDefault)
registerShortcut({
key: 'k',
ctrl: true,
alt: true,
onPress: openCommandPalette
})
// Escape key
registerShortcut({
key: 'Escape',
onPress: closeDialog
})
```
### With Debounce
```typescript
const { registerShortcut } = useKeyboardShortcuts()
// Prevent rapid successive triggers
registerShortcut({
key: 's',
ctrl: true,
onPress: autoSave,
debounce: 500 // Wait 500ms before processing
})
```
### API
```typescript
interface KeyboardShortcut {
key: string // Key name or character
ctrl?: boolean // Ctrl key (Windows/Linux)
cmd?: boolean // Cmd key (macOS)
shift?: boolean // Shift key
alt?: boolean // Alt key
onPress: () => void // Callback
preventDefault?: boolean // Prevent default action
debounce?: number // Debounce in ms
}
// Return types
registerShortcut(shortcut): string // Returns ID
unregister(id: string): void // Remove by ID
clearAll(): void // Remove all
```
---
## useClickOutside
Detect clicks outside a referenced element for modals, dropdowns, menus.
### Features
- Element reference tracking
- Open state management
- Support for excluded refs
- Touch event support
- Delay option
### Basic Usage
```typescript
import { useClickOutside } from '@metabuilder/hooks'
function Dropdown() {
const { ref, isOpen, setIsOpen } = useClickOutside()
return (
{isOpen && (
)}
)
}
```
### With Callback
```typescript
const { ref, isOpen, setIsOpen } = useClickOutside({
onClickOutside: () => {
console.log('Closed by outside click')
}
})
```
### Exclude Refs
```typescript
const triggerRef = useRef(null)
const dropdownRef = useClickOutside({
excludeRefs: [triggerRef] // Don't close when clicking trigger
})
return (
{isOpen && (
Menu
)}
)
```
### With Delay
```typescript
const { ref, isOpen, setIsOpen } = useClickOutside({
delayMs: 200 // Wait 200ms before closing
})
```
### API
```typescript
interface UseClickOutsideOptions {
onClickOutside?: () => void // Callback on outside click
excludeRefs?: React.RefObject[] // Refs to exclude
includeTouch?: boolean // Include touch events (default: true)
delayMs?: number // Delay before closing (ms)
}
// Returns
{
ref: React.RefObject // Attach to element
isOpen: boolean // Is currently open
setIsOpen: (open: boolean) => void // Set state
toggle: () => void // Toggle state
}
```
---
## useHotkeys
Global hotkey registration with combo key support (ctrl+shift+k style).
### Features
- Combo key parsing (ctrl+shift+enter)
- Platform-aware (cmd on Mac, ctrl on Windows)
- Handler registration
- Debounce support
- Enable/disable per hotkey
### Basic Usage
```typescript
import { useHotkeys } from '@metabuilder/hooks'
function SearchBox() {
const hotkeys = useHotkeys()
useEffect(() => {
// Command palette: Ctrl+K
hotkeys.register('ctrl+k', () => {
openCommandPalette()
})
// Escape to close
hotkeys.register('Escape', () => {
closeCommandPalette()
})
}, [hotkeys])
return
}
```
### Combo Key Formats
```typescript
const hotkeys = useHotkeys()
// Supported format: modifiers+key
hotkeys.register('ctrl+s', save) // Ctrl+S
hotkeys.register('ctrl+shift+k', search) // Ctrl+Shift+K
hotkeys.register('cmd+opt+i', inspector) // Cmd+Opt+I (Mac: opt=alt)
hotkeys.register('shift+enter', submitForm) // Shift+Enter
hotkeys.register('alt+1', switchTab1) // Alt+1
// Single key
hotkeys.register('Escape', closeMenu)
```
### With Options
```typescript
const hotkeys = useHotkeys()
const id = hotkeys.register('ctrl+shift+k', openPalette, {
preventDefault: true, // Prevent browser defaults
enabled: true, // Enable/disable this hotkey
debounceMs: 100 // Debounce rapid presses
})
// Later unregister
hotkeys.unregister(id)
```
### Platform-Specific
```typescript
const hotkeys = useHotkeys()
// Automatically uses Cmd on Mac, Ctrl on Windows
hotkeys.register('ctrl+s', save)
// You can also manually handle it
const saveKey = navigator.platform.includes('Mac') ? 'cmd+s' : 'ctrl+s'
hotkeys.register(saveKey, save)
```
### API
```typescript
interface HotkeysOptions {
preventDefault?: boolean // Prevent default action
enabled?: boolean // Enable/disable (default: true)
debounceMs?: number // Debounce delay
}
// Returns
{
register(combo, handler, options?): string // Returns ID
unregister(id: string): void // Remove by ID
unregisterAll(): void // Remove all
}
```
---
## useEventListener
Generic event listener with proper cleanup for any event type.
### Features
- Generic typing for any event
- Passive listener support (scroll/touch performance)
- Works with window, document, element
- Automatic cleanup
- Capture phase support
- Type-safe handlers
### Basic Usage
```typescript
import { useEventListener } from '@metabuilder/hooks'
function ResizeHandler() {
const { add } = useEventListener()
useEffect(() => {
const remove = add(window, 'resize', (e: UIEvent) => {
console.log('Window resized')
})
return remove
}, [add])
return Listen for resizes
}
```
### Element Events
```typescript
const { add } = useEventListener()
const inputRef = useRef(null)
useEffect(() => {
if (!inputRef.current) return
const remove = add(
inputRef.current,
'input',
(e: Event) => {
const target = e.target as HTMLInputElement
console.log('Value:', target.value)
}
)
return remove
}, [add])
```
### Passive Listeners
```typescript
const { add } = useEventListener()
// Passive listeners for better scroll performance
add(window, 'scroll', (e: Event) => {
console.log('Scrolling')
}, {
passive: true // Won't call preventDefault
})
add(window, 'touchmove', (e: TouchEvent) => {
console.log('Touch moving')
}, {
passive: true // Better mobile performance
})
```
### Capture Phase
```typescript
const { add } = useEventListener()
// Capture phase (runs before bubble phase)
add(document, 'click', (e: MouseEvent) => {
console.log('Clicked in capture phase')
}, {
capture: true
})
```
### Document Events
```typescript
const { add } = useEventListener()
add(document, 'click', (e: MouseEvent) => {
console.log('Clicked at', e.clientX, e.clientY)
})
add(document, 'keydown', (e: KeyboardEvent) => {
console.log('Key:', e.key)
})
```
### Multiple Listeners
```typescript
const { add, removeAll } = useEventListener()
useEffect(() => {
// Add multiple listeners
add(window, 'resize', handleResize)
add(window, 'scroll', handleScroll)
add(document, 'click', handleClick)
// Clean up all at once
return removeAll
}, [add, removeAll])
```
### API
```typescript
interface EventListenerOptions extends AddEventListenerOptions {
passive?: boolean // Better performance (default: false)
capture?: boolean // Use capture phase (default: false)
once?: boolean // Auto-remove after first trigger
}
// Returns
{
add(target, event, handler, options?): () => void // Returns cleanup fn
remove(target, event, handler, options?): void // Manual removal
removeAll(): void // Remove all listeners
}
// Types
type EventHandler = (event: T) => void
```
---
## Best Practices
### 1. Always Clean Up
```typescript
// ✅ Good - cleanup on unmount
useEffect(() => {
const id = registerShortcut(...)
return () => unregister(id)
}, [])
// ❌ Bad - leaves listeners
const { registerShortcut } = useKeyboardShortcuts()
registerShortcut(...) // Never cleaned up
```
### 2. Use TypeScript
```typescript
// ✅ Good - typed events
const { add } = useEventListener()
add(window, 'resize', (e: UIEvent) => {
// e is properly typed
}, { passive: true })
// ❌ Avoid - any types
add(window, 'resize', (e: any) => {})
```
### 3. Respect Passive Listeners
```typescript
// ✅ Good - passive for scroll
add(window, 'scroll', handleScroll, { passive: true })
// ⚠️ Can't preventDefault with passive
add(window, 'touchstart', (e: TouchEvent) => {
// e.preventDefault() won't work here
}, { passive: true })
```
### 4. Platform Detection
```typescript
// ✅ Good - useKeyboardShortcuts handles it
const { registerShortcut } = useKeyboardShortcuts()
registerShortcut({
key: 's',
ctrl: true, // Automatic: Cmd on Mac, Ctrl elsewhere
onPress: save
})
// ✅ Also good - useHotkeys combo parsing
const hotkeys = useHotkeys()
hotkeys.register('ctrl+s', save) // Auto cross-platform
```
### 5. Avoid Memory Leaks
```typescript
// ✅ Good
useEffect(() => {
const { ref, isOpen, setIsOpen } = useClickOutside()
return () => {
// useClickOutside cleans up automatically
}
}, [])
// ✅ Also good
const { add, removeAll } = useEventListener()
useEffect(() => {
add(window, 'resize', handleResize)
return removeAll // Clean everything
}, [])
```
---
## Performance Tips
1. **Use passive listeners for scroll/touch** - prevents jank
2. **Debounce rapid keyboard shortcuts** - avoid processing storms
3. **Use capture phase rarely** - default bubble is usually better
4. **Remove unused hotkeys** - don't let them pile up
5. **Group related listeners** - use one useEventListener for related events
---
## Migration Guide
### From Native Event Listeners
```typescript
// Before
useEffect(() => {
const handler = () => console.log('Resized')
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
// After
const { add } = useEventListener()
useEffect(() => {
return add(window, 'resize', () => console.log('Resized'))
}, [add])
```
### From Custom Shortcut Handlers
```typescript
// Before
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
save()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
// After
const { registerShortcut } = useKeyboardShortcuts()
useEffect(() => {
const id = registerShortcut({
key: 's',
ctrl: true,
onPress: save,
preventDefault: true
})
return () => unregister(id)
}, [])
```
---
## Troubleshooting
### Shortcuts not triggering
- Check key name matches `event.key` exactly
- Verify platform (Mac uses cmd, not ctrl)
- Ensure preventDefault isn't conflicting
### Click outside not closing
- Add element ref to the target element
- Verify trigger button isn't in excludeRefs unintentionally
- Check for nested refs with event propagation
### Memory leaks
- Always return cleanup function from useEffect
- Call removeAll() or unregister() for cleanup
- Don't create multiple hook instances unnecessarily
### Event handler not firing
- Verify event name is correct (e.g., 'input' vs 'change')
- Check passive listeners can't preventDefault
- Ensure target element exists in DOM