/ ink / focus.ts
focus.ts
  1  import type { DOMElement } from './dom.js'
  2  import { FocusEvent } from './events/focus-event.js'
  3  
  4  const 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   */
 15  export 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  
134  function collectTabbable(root: DOMElement): DOMElement[] {
135    const result: DOMElement[] = []
136    walkTree(root, result)
137    return result
138  }
139  
140  function 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  
153  function 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   */
166  export 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   */
179  export function getFocusManager(node: DOMElement): FocusManager {
180    return getRootNode(node).focusManager!
181  }