Skip to content

Bug / Enhancement: FocusManager.trap() does not save and restore pre-trap focus on release() #1745

@palak170306-design

Description

@palak170306-design

Which package?

@termuijs/core

What happened?

FocusManager.trap() moves focus to the first widget inside the trap container, but never
records which widget had focus before the trap was activated. When release() is called,
it only pops the trap stack — focus remains wherever it was inside the now-dismissed modal
container.

Relevant source (FocusManager.ts):

trap(containerId: string): void {
    this._trapStack.push(containerId);
    // Moves focus into the trap — pre-trap focus ID is never saved
    const trapped = this._getActiveFocusables();
    if (trapped.length > 0) {
        const first = trapped.find(f => f.focusable);
        if (first) {
            const idx = this._focusables.findIndex(f => f.id === first.id);
            if (idx >= 0) this._changeFocus(idx);
        }
    }
}

release(): void {
    this._trapStack.pop();
    // No focus restoration — focus is now floating inside a closed modal
}

This violates the ARIA Authoring Practices Guide for modal dialogs:

"When the dialog closes, focus returns to the element that invoked the dialog."

In a terminal app with keyboard-only navigation, this means after closing a modal the user
has no way to determine where focus is, and the next Tab/arrow keypress resumes navigation
from a widget inside the now-invisible modal container.

Expected: After release(), focus returns to the widget that had focus before trap()
was called.
Actual: Focus remains on the last focused widget inside the trap container.

Steps to reproduce

```typescript
import { FocusManager } from '@termuijs/core';

const fm = new FocusManager();
fm.start();

fm.register({ id: 'open-modal-button', tabIndex: 0, focusable: true });
fm.register({ id: 'modal-input', tabIndex: 1, focusable: true });
fm.register({ id: 'modal-confirm', tabIndex: 2, focusable: true });

fm.focusWidget('open-modal-button');
console.log(fm.currentId); // 'open-modal-button'

fm.registerContainerMembers('dialog', ['modal-input', 'modal-confirm']);
fm.trap('dialog');
console.log(fm.currentId); // 'modal-input' — focus moved into modal

fm.focusNext();
console.log(fm.currentId); // 'modal-confirm'

fm.release();
console.log(fm.currentId);
// Actual: 'modal-confirm' ← still inside the closed modal
// Expected: 'open-modal-button' ← restored to pre-modal trigger
```

Environment

  • TermUI version: latest (main branch)
  • Node.js 18+
  • Any OS / terminal

GSSoC contributor?

  • Yes. You contribute under GSSoC 2026.
  • No.

Metadata

Metadata

Labels

assignedIssue claimed by a contributor.type:feature+10 pts. New feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions