182 lines
5.0 KiB
TypeScript
182 lines
5.0 KiB
TypeScript
import type { DOMElement } from './dom.js'
|
||
import { FocusEvent } from './events/focus-event.js'
|
||
|
||
const MAX_FOCUS_STACK = 32
|
||
|
||
/**
|
||
* FocusManager - Ink 终端 UI 的类 DOM 焦点管理器
|
||
*
|
||
* 纯状态管理 —— 追踪 activeElement 和焦点栈。
|
||
* 不引用树结构;调用者在需要树遍历时传入根节点。
|
||
*
|
||
* 存储在根 DOMElement 上,以便任何节点可以通过 walking
|
||
* parentNode 访问它(类似浏览器的 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) {
|
||
// 推送前去重,防止 Tab 循环时无限增长
|
||
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))
|
||
}
|
||
|
||
/**
|
||
* 当节点从树中移除时由 reconciler 调用。
|
||
* 处理被移除节点及其在已移除子树中的任何焦点后代。
|
||
* 分发 blur 并从栈中恢复焦点。
|
||
*/
|
||
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
|
||
// 从栈中移除该节点及其后代
|
||
this.focusStack = this.focusStack.filter(
|
||
n => n !== node && isInTree(n, root),
|
||
)
|
||
|
||
// 检查 activeElement 是否是被移除的节点或其后代
|
||
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))
|
||
|
||
// 恢复焦点到最近仍挂载的元素
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 向上走到根节点并返回。根节点是持有 FocusManager 的节点——
|
||
* 类似浏览器的 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')
|
||
}
|
||
|
||
/**
|
||
* 向上走到根节点并返回其 FocusManager。
|
||
* 类似浏览器的 node.ownerDocument —— 焦点属于根节点。
|
||
*/
|
||
export function getFocusManager(node: DOMElement): FocusManager {
|
||
return getRootNode(node).focusManager!
|
||
}
|