init claude-code
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
import 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!
|
||||
}
|
||||
Reference in New Issue
Block a user