Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
5.1 kB · 181 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182import type { DOMElement } from './dom.js'import { FocusEvent } from './events/focus-event.js'
const MAX_FOCUS_STACK = 32
/** * DOM-like focus manager for the Ink terminal UI. * * Pure state — tracks activeElement and a focus stack. Has no reference * to the tree; callers pass the root when tree walks are needed. * * Stored on the root DOMElement so any node can reach it by walking * parentNode (like browser's `node.ownerDocument`). */export class FocusManager { activeElement: DOMElement | null = null private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean private enabled = true private focusStack: DOMElement[] = []
constructor( dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean, ) { this.dispatchFocusEvent = dispatchFocusEvent }
focus(node: DOMElement): void { if (node === this.activeElement) return if (!this.enabled) return
const previous = this.activeElement if (previous) { // Deduplicate before pushing to prevent unbounded growth from Tab cycling const idx = this.focusStack.indexOf(previous) if (idx !== -1) this.focusStack.splice(idx, 1) this.focusStack.push(previous) if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift() this.dispatchFocusEvent(previous, new FocusEvent('blur', node)) } this.activeElement = node this.dispatchFocusEvent(node, new FocusEvent('focus', previous)) }
blur(): void { if (!this.activeElement) return
const previous = this.activeElement this.activeElement = null this.dispatchFocusEvent(previous, new FocusEvent('blur', null)) }
/** * Called by the reconciler when a node is removed from the tree. * Handles both the exact node and any focused descendant within * the removed subtree. Dispatches blur and restores focus from stack. */ handleNodeRemoved(node: DOMElement, root: DOMElement): void { // Remove the node and any descendants from the stack this.focusStack = this.focusStack.filter( n => n !== node && isInTree(n, root), )
// Check if activeElement is the removed node OR a descendant if (!this.activeElement) return if (this.activeElement !== node && isInTree(this.activeElement, root)) { return }
const removed = this.activeElement this.activeElement = null this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
// Restore focus to the most recent still-mounted element while (this.focusStack.length > 0) { const candidate = this.focusStack.pop()! if (isInTree(candidate, root)) { this.activeElement = candidate this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed)) return } } }
handleAutoFocus(node: DOMElement): void { this.focus(node) }
handleClickFocus(node: DOMElement): void { const tabIndex = node.attributes['tabIndex'] if (typeof tabIndex !== 'number') return this.focus(node) }
enable(): void { this.enabled = true }
disable(): void { this.enabled = false }
focusNext(root: DOMElement): void { this.moveFocus(1, root) }
focusPrevious(root: DOMElement): void { this.moveFocus(-1, root) }
private moveFocus(direction: 1 | -1, root: DOMElement): void { if (!this.enabled) return
const tabbable = collectTabbable(root) if (tabbable.length === 0) return
const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1
const nextIndex = currentIndex === -1 ? direction === 1 ? 0 : tabbable.length - 1 : (currentIndex + direction + tabbable.length) % tabbable.length
const next = tabbable[nextIndex] if (next) { this.focus(next) } }}
function collectTabbable(root: DOMElement): DOMElement[] { const result: DOMElement[] = [] walkTree(root, result) return result}
function walkTree(node: DOMElement, result: DOMElement[]): void { const tabIndex = node.attributes['tabIndex'] if (typeof tabIndex === 'number' && tabIndex >= 0) { result.push(node) }
for (const child of node.childNodes) { if (child.nodeName !== '#text') { walkTree(child, result) } }}
function isInTree(node: DOMElement, root: DOMElement): boolean { let current: DOMElement | undefined = node while (current) { if (current === root) return true current = current.parentNode } return false}
/** * Walk up to root and return it. The root is the node that holds * the FocusManager — like browser's `node.getRootNode()`. */export function getRootNode(node: DOMElement): DOMElement { let current: DOMElement | undefined = node while (current) { if (current.focusManager) return current current = current.parentNode } throw new Error('Node is not in a tree with a FocusManager')}
/** * Walk up to root and return its FocusManager. * Like browser's `node.ownerDocument` — focus belongs to the root. */export function getFocusManager(node: DOMElement): FocusManager { return getRootNode(node).focusManager!}