source dump of claude code
at main 181 lines 5.1 kB view raw
1import type { DOMElement } from './dom.js' 2import { FocusEvent } from './events/focus-event.js' 3 4const MAX_FOCUS_STACK = 32 5 6/** 7 * DOM-like focus manager for the Ink terminal UI. 8 * 9 * Pure state — tracks activeElement and a focus stack. Has no reference 10 * to the tree; callers pass the root when tree walks are needed. 11 * 12 * Stored on the root DOMElement so any node can reach it by walking 13 * parentNode (like browser's `node.ownerDocument`). 14 */ 15export class FocusManager { 16 activeElement: DOMElement | null = null 17 private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean 18 private enabled = true 19 private focusStack: DOMElement[] = [] 20 21 constructor( 22 dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean, 23 ) { 24 this.dispatchFocusEvent = dispatchFocusEvent 25 } 26 27 focus(node: DOMElement): void { 28 if (node === this.activeElement) return 29 if (!this.enabled) return 30 31 const previous = this.activeElement 32 if (previous) { 33 // Deduplicate before pushing to prevent unbounded growth from Tab cycling 34 const idx = this.focusStack.indexOf(previous) 35 if (idx !== -1) this.focusStack.splice(idx, 1) 36 this.focusStack.push(previous) 37 if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift() 38 this.dispatchFocusEvent(previous, new FocusEvent('blur', node)) 39 } 40 this.activeElement = node 41 this.dispatchFocusEvent(node, new FocusEvent('focus', previous)) 42 } 43 44 blur(): void { 45 if (!this.activeElement) return 46 47 const previous = this.activeElement 48 this.activeElement = null 49 this.dispatchFocusEvent(previous, new FocusEvent('blur', null)) 50 } 51 52 /** 53 * Called by the reconciler when a node is removed from the tree. 54 * Handles both the exact node and any focused descendant within 55 * the removed subtree. Dispatches blur and restores focus from stack. 56 */ 57 handleNodeRemoved(node: DOMElement, root: DOMElement): void { 58 // Remove the node and any descendants from the stack 59 this.focusStack = this.focusStack.filter( 60 n => n !== node && isInTree(n, root), 61 ) 62 63 // Check if activeElement is the removed node OR a descendant 64 if (!this.activeElement) return 65 if (this.activeElement !== node && isInTree(this.activeElement, root)) { 66 return 67 } 68 69 const removed = this.activeElement 70 this.activeElement = null 71 this.dispatchFocusEvent(removed, new FocusEvent('blur', null)) 72 73 // Restore focus to the most recent still-mounted element 74 while (this.focusStack.length > 0) { 75 const candidate = this.focusStack.pop()! 76 if (isInTree(candidate, root)) { 77 this.activeElement = candidate 78 this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed)) 79 return 80 } 81 } 82 } 83 84 handleAutoFocus(node: DOMElement): void { 85 this.focus(node) 86 } 87 88 handleClickFocus(node: DOMElement): void { 89 const tabIndex = node.attributes['tabIndex'] 90 if (typeof tabIndex !== 'number') return 91 this.focus(node) 92 } 93 94 enable(): void { 95 this.enabled = true 96 } 97 98 disable(): void { 99 this.enabled = false 100 } 101 102 focusNext(root: DOMElement): void { 103 this.moveFocus(1, root) 104 } 105 106 focusPrevious(root: DOMElement): void { 107 this.moveFocus(-1, root) 108 } 109 110 private moveFocus(direction: 1 | -1, root: DOMElement): void { 111 if (!this.enabled) return 112 113 const tabbable = collectTabbable(root) 114 if (tabbable.length === 0) return 115 116 const currentIndex = this.activeElement 117 ? tabbable.indexOf(this.activeElement) 118 : -1 119 120 const nextIndex = 121 currentIndex === -1 122 ? direction === 1 123 ? 0 124 : tabbable.length - 1 125 : (currentIndex + direction + tabbable.length) % tabbable.length 126 127 const next = tabbable[nextIndex] 128 if (next) { 129 this.focus(next) 130 } 131 } 132} 133 134function collectTabbable(root: DOMElement): DOMElement[] { 135 const result: DOMElement[] = [] 136 walkTree(root, result) 137 return result 138} 139 140function walkTree(node: DOMElement, result: DOMElement[]): void { 141 const tabIndex = node.attributes['tabIndex'] 142 if (typeof tabIndex === 'number' && tabIndex >= 0) { 143 result.push(node) 144 } 145 146 for (const child of node.childNodes) { 147 if (child.nodeName !== '#text') { 148 walkTree(child, result) 149 } 150 } 151} 152 153function isInTree(node: DOMElement, root: DOMElement): boolean { 154 let current: DOMElement | undefined = node 155 while (current) { 156 if (current === root) return true 157 current = current.parentNode 158 } 159 return false 160} 161 162/** 163 * Walk up to root and return it. The root is the node that holds 164 * the FocusManager — like browser's `node.getRootNode()`. 165 */ 166export function getRootNode(node: DOMElement): DOMElement { 167 let current: DOMElement | undefined = node 168 while (current) { 169 if (current.focusManager) return current 170 current = current.parentNode 171 } 172 throw new Error('Node is not in a tree with a FocusManager') 173} 174 175/** 176 * Walk up to root and return its FocusManager. 177 * Like browser's `node.ownerDocument` — focus belongs to the root. 178 */ 179export function getFocusManager(node: DOMElement): FocusManager { 180 return getRootNode(node).focusManager! 181}