import type { DOMElement } from './dom.js'; import { FocusEvent } from './events/focus-event.js'; /** Upper bound on the size of the focus restoration stack. */ const MAX_FOCUS_STACK = 32; /** DOM-like focus manager. */ export class FocusManager { activeElement: DOMElement | null = null; private readonly dispatch: (target: DOMElement, event: FocusEvent) => boolean; private enabled = true; private focusStack: DOMElement[] = []; constructor(dispatch: (target: DOMElement, event: FocusEvent) => boolean) { this.dispatch = dispatch; } focus(node: DOMElement): void { if (node === this.activeElement) return; if (!this.enabled) return; const previous = this.activeElement; if (previous) { // Deduplicate before pushing: tab-cycling between a small set of // nodes would otherwise pin the stack at MAX_FOCUS_STACK forever // and silently drop earlier entries from real navigation history. const existing = this.focusStack.indexOf(previous); if (existing !== -1) this.focusStack.splice(existing, 1); this.focusStack.push(previous); if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift(); this.dispatch(previous, new FocusEvent('blur', node)); } this.activeElement = node; this.dispatch(node, new FocusEvent('focus', previous)); } blur(): void { if (!this.activeElement) return; const previous = this.activeElement; this.activeElement = null; this.dispatch(previous, new FocusEvent('blur', null)); } /** Called from the reconciler when a node leaves the tree. */ handleNodeRemoved(node: DOMElement, root: DOMElement): void { this.focusStack = this.focusStack.filter( (entry) => entry !== node && isInTree(entry, root), ); if (!this.activeElement) return; if (this.activeElement !== node && isInTree(this.activeElement, root)) { return; } const removed = this.activeElement; this.activeElement = null; this.dispatch(removed, new FocusEvent('blur', null)); while (this.focusStack.length > 0) { const candidate = this.focusStack.pop()!; if (isInTree(candidate, root)) { this.activeElement = candidate; this.dispatch(candidate, new FocusEvent('focus', removed)); return; } } } handleAutoFocus(node: DOMElement): void { this.focus(node); } /** Click-to-focus. */ 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 collected: DOMElement[] = []; walkTree(root, collected); return collected; } function walkTree(node: DOMElement, collected: DOMElement[]): void { const tabIndex = node.attributes['tabIndex']; if (typeof tabIndex === 'number' && tabIndex >= 0) { collected.push(node); } for (const child of node.childNodes) { if (child.nodeName !== '#text') { walkTree(child, collected); } } } 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 the root DOMElement and return it. */ 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 attached to a tree that owns a FocusManager'); } export function getFocusManager(node: DOMElement): FocusManager { return getRootNode(node).focusManager!; }