(
// Handler returning `false` means "not consumed" — the event propagates
// to later useInput/useKeybindings handlers. Useful for fall-through:
// e.g. ScrollKeybindingHandler's scroll:line* returns false when the
// ScrollBox content fits (scroll is a no-op), letting a child component's
// handler take the wheel event for list navigation instead. Promise<void>
// is allowed for fire-and-forget async handlers (the `!== false` check
// only skips propagation for a sync `false`, not a pending Promise).
handlers: Record<string, () => void | false | Promise<void>>,
options: Options = {},
)
| 111 | * ``` |
| 112 | */ |
| 113 | export function useKeybindings( |
| 114 | // Handler returning `false` means "not consumed" — the event propagates |
| 115 | // to later useInput/useKeybindings handlers. Useful for fall-through: |
| 116 | // e.g. ScrollKeybindingHandler's scroll:line* returns false when the |
| 117 | // ScrollBox content fits (scroll is a no-op), letting a child component's |
| 118 | // handler take the wheel event for list navigation instead. Promise<void> |
| 119 | // is allowed for fire-and-forget async handlers (the `!== false` check |
| 120 | // only skips propagation for a sync `false`, not a pending Promise). |
| 121 | handlers: Record<string, () => void | false | Promise<void>>, |
| 122 | options: Options = {}, |
| 123 | ): void { |
| 124 | const { context = 'Global', isActive = true } = options |
| 125 | const keybindingContext = useOptionalKeybindingContext() |
| 126 | |
| 127 | // Register all handlers with the context for ChordInterceptor to invoke |
| 128 | useEffect(() => { |
| 129 | if (!keybindingContext || !isActive) return |
| 130 | |
| 131 | const unregisterFns: Array<() => void> = [] |
| 132 | for (const [action, handler] of Object.entries(handlers)) { |
| 133 | unregisterFns.push( |
| 134 | keybindingContext.registerHandler({ action, context, handler }), |
| 135 | ) |
| 136 | } |
| 137 | |
| 138 | return () => { |
| 139 | for (const unregister of unregisterFns) { |
| 140 | unregister() |
| 141 | } |
| 142 | } |
| 143 | }, [context, handlers, keybindingContext, isActive]) |
| 144 | |
| 145 | const handleInput = useCallback( |
| 146 | (input: string, key: Key, event: InputEvent) => { |
| 147 | // If no keybinding context available, skip resolution |
| 148 | if (!keybindingContext) return |
| 149 | |
| 150 | // Build context list: registered active contexts + this context + Global |
| 151 | // More specific contexts (registered ones) take precedence over Global |
| 152 | const contextsToCheck: KeybindingContextName[] = [ |
| 153 | ...keybindingContext.activeContexts, |
| 154 | context, |
| 155 | 'Global', |
| 156 | ] |
| 157 | // Deduplicate while preserving order (first occurrence wins for priority) |
| 158 | const uniqueContexts = [...new Set(contextsToCheck)] |
| 159 | |
| 160 | const result = keybindingContext.resolve(input, key, uniqueContexts) |
| 161 | |
| 162 | switch (result.type) { |
| 163 | case 'match': |
| 164 | // Chord completed (if any) - clear pending state |
| 165 | keybindingContext.setPendingChord(null) |
| 166 | if (result.action in handlers) { |
| 167 | const handler = handlers[result.action] |
| 168 | if (handler && handler() !== false) { |
| 169 | event.stopImmediatePropagation() |
| 170 | } |
no test coverage detected