diff --git a/packages/core/src/input/focusLock.ts b/packages/core/src/input/focusLock.ts new file mode 100644 index 000000000..b9ed3255f --- /dev/null +++ b/packages/core/src/input/focusLock.ts @@ -0,0 +1,61 @@ +// ───────────────────────────────────────────────────── +// @termuijs/core — Scoped Keyboard Focus Lock & Modal Manager +// ───────────────────────────────────────────────────── + +import type { FocusManager, Focusable } from '../events/FocusManager.js'; + +export interface TrapOptions { + containerId: string; +} + +export class FocusLockManager { + private _focusManager: FocusManager; + private _trapStack: string[] = []; + + constructor(focusManager: FocusManager) { + this._focusManager = focusManager; + } + + get isLocked(): boolean { + return this._trapStack.length > 0; + } + + get activeTrapContainerId(): string | null { + if (this._trapStack.length === 0) return null; + return this._trapStack[this._trapStack.length - 1]; + } + + pushTrap(options: TrapOptions, focusables: Focusable[] = []): void { + this._trapStack.push(options.containerId); + this._focusManager.trap(options.containerId, focusables); + } + + popTrap(): string | null { + if (this._trapStack.length === 0) return null; + + const popped = this._trapStack.pop()!; + this._focusManager.release(); + + return popped; + } + + handleKeyDown(keyName: string, isShift: boolean = false): boolean { + if (!this.isLocked) return false; + + if (keyName === 'escape') { + this.popTrap(); + return true; + } + + if (keyName === 'tab') { + if (isShift) { + this._focusManager.focusPrev(); + } else { + this._focusManager.focusNext(); + } + return true; + } + + return false; + } +} diff --git a/packages/core/test/focusLock.test.ts b/packages/core/test/focusLock.test.ts new file mode 100644 index 000000000..8b5aed6d8 --- /dev/null +++ b/packages/core/test/focusLock.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, beforeEach } from 'bun:test'; +import { FocusManager } from '../src/events/FocusManager.js'; +import { FocusLockManager } from '../src/input/focusLock.js'; + +describe('FocusLockManager', () => { + let focusManager: FocusManager; + let focusLock: FocusLockManager; + + beforeEach(() => { + focusManager = new FocusManager(); + focusLock = new FocusLockManager(focusManager); + + focusManager.register({ id: 'bg-btn', tabIndex: 1, focusable: true }); + focusManager.register({ id: 'modal-btn1', tabIndex: 2, focusable: true }); + focusManager.register({ id: 'modal-btn2', tabIndex: 3, focusable: true }); + focusManager.start(); + }); + + test('pushes trap and locks focus within container', () => { + expect(focusLock.isLocked).toBe(false); + + focusLock.pushTrap( + { containerId: 'modal-1' }, + [ + { id: 'modal-btn1', tabIndex: 1, focusable: true }, + { id: 'modal-btn2', tabIndex: 2, focusable: true }, + ] + ); + + expect(focusLock.isLocked).toBe(true); + expect(focusLock.activeTrapContainerId).toBe('modal-1'); + }); + + test('handles tab navigation cycling inside trap', () => { + focusLock.pushTrap({ containerId: 'modal-1' }, [ + { id: 'modal-btn1', tabIndex: 1, focusable: true }, + { id: 'modal-btn2', tabIndex: 2, focusable: true }, + ]); + + const handled = focusLock.handleKeyDown('tab'); + expect(handled).toBe(true); + }); + + test('pops trap on escape key', () => { + focusLock.pushTrap({ containerId: 'modal-1' }, [ + { id: 'modal-btn1', tabIndex: 1, focusable: true }, + ]); + + const handled = focusLock.handleKeyDown('escape'); + expect(handled).toBe(true); + expect(focusLock.isLocked).toBe(false); + }); +});