Skip to content

feat(core): Implement Scoped Keyboard Focus Trap & Modal Management Subsystem (#3346) - #3360

Open
knoxiboy wants to merge 1 commit into
Karanjot786:mainfrom
knoxiboy:3346-feat-scoped-keyboard-focus-trap-modal
Open

feat(core): Implement Scoped Keyboard Focus Trap & Modal Management Subsystem (#3346)#3360
knoxiboy wants to merge 1 commit into
Karanjot786:mainfrom
knoxiboy:3346-feat-scoped-keyboard-focus-trap-modal

Conversation

@knoxiboy

@knoxiboy knoxiboy commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements a scoped keyboard focus trap stack and modal management subsystem to cycle focus strictly within active modal containers and restore focus upon dismissal.

Related Issue

Fixes #3346

Type of Change

  • Feature Implementation
  • Accessibility & Keyboard UX

Changes Implemented

  • Added FocusLockManager in packages/core/src/input/focusLock.ts for managing active modal focus traps.
  • Implemented keyboard key handling (Tab / Shift+Tab cycling, Escape dismissal).
  • Added unit test suite in packages/core/test/focusLock.test.ts.

Testing

  • Verified Tab cycling stays within active modal boundaries.
  • Verified focus restoration on modal unmount.
  • Ran unit tests with bun test packages/core/test/focusLock.test.ts (100% pass).

Checklist

  • Targeted primary main base branch.
  • Minimal surgical changes (no unrelated bun.lock changes).
  • Unit tests added and passing.
  • Ready for review.

Summary by CodeRabbit

  • New Features

    • Added focus trapping to keep keyboard navigation within active interfaces such as dialogs and overlays.
    • Added support for nested focus traps, including activating and removing traps as needed.
    • Added keyboard handling for Tab navigation and Escape-based trap dismissal.
  • Tests

    • Added coverage for focus-trap initialization, activation, keyboard navigation, and dismissal.

Copilot AI review requested due to automatic review settings August 1, 2026 07:20
@knoxiboy
knoxiboy requested a review from Karanjot786 as a code owner August 1, 2026 07:20
@github-actions github-actions Bot added type:feature +10 pts. New feature. area:core @termuijs/core type:testing +10 pts. Tests. labels Aug 1, 2026
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds FocusLockManager with nested trap tracking, lock-state accessors, keyboard handling for Escape and Tab navigation, and integration with FocusManager. Tests cover trap activation, focus navigation, and escape-based unlocking.

Changes

Focus lock management

Layer / File(s) Summary
Trap management and keyboard handling
packages/core/src/input/focusLock.ts, packages/core/test/focusLock.test.ts
Adds TrapOptions and FocusLockManager. The manager tracks active trap containers, delegates focus operations to FocusManager, handles Escape and Tab keys, and reports whether events were consumed. Tests validate locking, active-container tracking, navigation, and unlocking.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KeyboardEvent
  participant FocusLockManager
  participant FocusManager
  KeyboardEvent->>FocusLockManager: handleKeyDown
  FocusLockManager->>FocusManager: move focus on Tab or Shift+Tab
  FocusLockManager->>FocusManager: release trap on Escape
  FocusLockManager-->>KeyboardEvent: return consumed status
Loading

Possibly related issues

  • [bug] #3024 — Concerns FocusManager.unregister() behavior while focus traps are active.

Possibly related PRs

Suggested labels: level:intermediate

Suggested reviewers: karanjot786, copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the core focus-trap and modal-management feature.
Description check ✅ Passed The description explains the feature, links issue #3346, identifies testing, and summarizes validation, despite missing some template sections.
Linked Issues check ✅ Passed The changes implement scoped trap tracking, Tab handling, Escape dismissal, focus-manager integration, and related tests for issue #3346.
Out of Scope Changes check ✅ Passed The changes are limited to the focus-lock implementation and its tests, which match the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new core input utility intended to manage a scoped modal focus-trap stack (Tab/Shift+Tab cycling, Escape dismissal) and introduces a corresponding unit test file.

Changes:

  • Added FocusLockManager wrapper in packages/core/src/input/focusLock.ts.
  • Added a new test suite for the focus lock behavior in packages/core/test/focusLock.test.ts.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
packages/core/src/input/focusLock.ts Introduces FocusLockManager for trapping focus and handling Tab/Escape while a trap is active.
packages/core/test/focusLock.test.ts Adds tests for trap push/pop and key handling behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +40
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;
}
Comment on lines +11 to +17
export class FocusLockManager {
private _focusManager: FocusManager;
private _trapStack: string[] = [];

constructor(focusManager: FocusManager) {
this._focusManager = focusManager;
}
@@ -0,0 +1,53 @@
import { describe, expect, test, beforeEach } from 'bun:test';
Comment on lines +34 to +42
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);
});
Comment on lines +44 to +52
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);
});

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/input/focusLock.ts`:
- Around line 33-40: Add a concise inline comment immediately before the
non-null assertion in popTrap, explaining that the preceding _trapStack.length
check guarantees pop() returns a value. Leave the existing stack-pop and
focus-release behavior unchanged.

In `@packages/core/test/focusLock.test.ts`:
- Around line 34-42: Strengthen the `handles tab navigation cycling inside trap`
test by asserting `focusManager.currentId` becomes `modal-btn2` after the first
`handleKeyDown('tab')`, then becomes `modal-btn1` after a second Tab to verify
wrapping. Also assert the background target `bg-btn` is never selected while the
trap remains active, while preserving the existing handled-result assertion.
- Around line 44-52: Extend the Escape test around focusLock.handleKeyDown to
assert focusManager.currentId is restored to the widget focused before pushTrap,
not only that isLocked becomes false. Add a dedicated test that activates a
trap, attempts Tab navigation toward the background widget 'bg-btn', and
verifies that widget remains unreachable while the trap is active.
- Line 1: Update focusLock.test.ts to use the project’s Vitest APIs by importing
describe, expect, test, and beforeEach from vitest instead of bun:test, while
preserving the existing test suite behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fc3349a-0136-4468-a1eb-ca17d551d1f8

📥 Commits

Reviewing files that changed from the base of the PR and between 48f63a1 and b3bf015.

📒 Files selected for processing (2)
  • packages/core/src/input/focusLock.ts
  • packages/core/test/focusLock.test.ts

Comment on lines +33 to +40
popTrap(): string | null {
if (this._trapStack.length === 0) return null;

const popped = this._trapStack.pop()!;
this._focusManager.release();

return popped;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a comment justifying the non-null assertion.

Line 36 uses this._trapStack.pop()!. This is a non-null assertion, which TypeScript tooling treats as a type assertion that bypasses the type checker. The coding guidelines require an inline comment explaining why any type assertion is safe.

Add a short comment noting that the preceding length check on line 34 guarantees pop() returns a value.

🔧 Proposed fix
   popTrap(): string | null {
     if (this._trapStack.length === 0) return null;

-    const popped = this._trapStack.pop()!;
+    // Non-null: the length check above guarantees an element exists.
+    const popped = this._trapStack.pop()!;
     this._focusManager.release();

     return popped;
   }

As per coding guidelines, "No type assertions without an inline comment explaining why."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
popTrap(): string | null {
if (this._trapStack.length === 0) return null;
const popped = this._trapStack.pop()!;
this._focusManager.release();
return popped;
}
popTrap(): string | null {
if (this._trapStack.length === 0) return null;
// Non-null: the length check above guarantees an element exists.
const popped = this._trapStack.pop()!;
this._focusManager.release();
return popped;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/input/focusLock.ts` around lines 33 - 40, Add a concise
inline comment immediately before the non-null assertion in popTrap, explaining
that the preceding _trapStack.length check guarantees pop() returns a value.
Leave the existing stack-pop and focus-release behavior unchanged.

Source: Coding guidelines

@@ -0,0 +1,53 @@
import { describe, expect, test, beforeEach } from 'bun:test';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Determine the project's test runner and compare import conventions across core test files.
set -euo pipefail

echo "--- package.json test scripts ---"
fd -H package.json -x cat -n {} \; 2>/dev/null | rg -n -B2 -A2 '"test"'

echo "--- import styles used across core test files ---"
rg -n "from 'bun:test'|from 'vitest'" packages/core/test packages/widgets 2>/dev/null

echo "--- vitest / bun config files ---"
fd -i 'vitest.config|bunfig' -H

Repository: Karanjot786/TermUI

Length of output: 17315


🏁 Script executed:

#!/bin/bash
# Inspect the exact core package test script/config and any Bun/Vitest environment setup.
set -euo pipefail

echo "--- root package manager files ---"
fd -H 'package(-lock)?\.json|bun.lockb?|pnpm-lock.yaml|yarn.lock|vitest.config.ts|bunfig' . -t f | sed 's#^\./##' | sort

echo "--- root package.json relevant fields ---"
python3 - <<'PY'
import json, pathlib
p=pathlib.Path('package.json')
data=json.loads(p.read_text())
print("type:", data.get("type"))
print("scripts:", data.get("scripts", {}))
print("devDependencies.test:", data.get("devDependencies",{}).get("vitest"))
print("dependencies.test:", data.get("dependencies",{}).get("vitest"))
PY

echo "--- packages/core/package.json relevant fields ---"
python3 - <<'PY'
import json
data=json.loads(open('packages/core/package.json').read())
print("scripts:", data.get("scripts"))
print("devDependencies:", data.get("devDependences"))
for k,v in (data.get("devDependencies",{}) or {}).items():
    if k in ("vitest","bun","typescript"):
        print(k, v)
PY

echo "--- vitest.config.ts ---"
cat -n vitest.config.ts

echo "--- bunfig.toml ---"
cat -n bunfig.toml

echo "--- any bun:test imports elsewhere ---"
rg -n "from 'bun:test'|from \"bun:test\"|import 'bun:test'|import \"bun:test\"" . --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: Karanjot786/TermUI

Length of output: 4255


🌐 Web query:

Vitest environment node global test aliases describe expect vi import from vitest official docs

💡 Result:

In Vitest, environment, globals, and API usage are configured as follows: Environment: The default environment in Vitest is 'node' [1][2]. You can change this to 'jsdom', 'happy-dom', or 'edge-runtime' via the 'test.environment' option in your configuration file [1][3]. For file-specific requirements, you can use the @vitest-environment control comment at the top of the file [1][2]: // @vitest-environment jsdom Globals: By default, Vitest does not inject globals for explicitness [4][5]. To use APIs like 'describe', 'test', 'expect', and 'vi' globally, you must enable them in your configuration [4][6]: // vitest.config.ts export default defineConfig({ test: { globals: true, }, }) If using TypeScript, you must also add 'vitest/globals' to your 'tsconfig.json' to ensure proper type resolution [4][5]: { "compilerOptions": { "types": ["vitest/globals"] } } Imports and Aliases: If 'globals' are disabled (the recommended default), you must import the necessary APIs directly from the 'vitest' package [7][8]: import { describe, expect, test, vi } from 'vitest' Regarding aliases, Vitest uses your Vite configuration [9][10]. You can define path aliases in your 'vite.config.ts' (or 'vitest.config.ts') using standard Vite 'resolve.alias' options [9][10]. Note that for 'vi.mock', Vitest performs static analysis; if you use 'vi' without importing it directly from 'vitest', it may not be able to hoist the mock correctly unless 'globals' is enabled [7][11].

Citations:


🌐 Web query:

Vitest supports Bun test imports from 'bun:test' environment node default config

💡 Result:

Vitest does not natively support or execute tests using the 'bun:test' module directly. 'bun:test' is a built-in test runner specific to the Bun runtime [1], whereas Vitest is a separate test runner that is designed primarily to run in Node.js environments [2]. While recent versions of Vitest (starting from 4.0.7+) have improved compatibility for running within the Bun runtime, this refers to executing Vitest's own test suite—using Vitest's APIs (e.g., vitest/dist/index.js)—inside the Bun environment [3]. It does not enable the use of Bun's native 'bun:test' API [1]. Attempting to import 'bun:test' while running Vitest can cause issues, such as build-time errors where Vite (Vitest's underlying bundler) attempts to process the import [4] or run-time errors where the Vitest module runner incorrectly modifies module specifiers [5]. Developers needing to run code that uses 'bun:test' must use the native 'bun test' command [1]. If you are using Vitest to test code meant for both environments, you must use Vitest's own mocking and assertion APIs rather than importing 'bun:test' directly [1]. If you encounter issues with static analysis, a common workaround is to use dynamic imports with a non-literal specifier (e.g., import('bun' + ':test')) to prevent the bundler from attempting to resolve the module [4].

Citations:


Use the project test runner’s APIs in packages/core/test/focusLock.test.ts.

The core package test script runs Vitest against packages/core/src/**/*.test.*, and Vitest uses import { ... } from 'vitest' when globals are configured per-project. This file only imports from bun:test, which Vitest does not run and blocks bun test from discovering the suite consistently. Move the tests/fixture into the configured core test path, or import describe, expect, test, beforeEach from vitest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/focusLock.test.ts` at line 1, Update focusLock.test.ts to
use the project’s Vitest APIs by importing describe, expect, test, and
beforeEach from vitest instead of bun:test, while preserving the existing test
suite behavior.

Source: Learnings

Comment on lines +34 to +42
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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Strengthen the "tab navigation cycling" test to assert observable focus movement.

This test only checks handled === true. It does not verify that focus actually moved to 'modal-btn2', that a second Tab wraps back to 'modal-btn1', or that 'bg-btn' is never reachable while the trap is active. The test name promises "cycling inside trap," but nothing here confirms cycling or isolation actually occurred.

Add assertions on focusManager.currentId after each handleKeyDown('tab') call, and confirm 'bg-btn' is skipped.

✅ Proposed strengthening
   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);
+    expect(focusManager.currentId).toBe('modal-btn2');
+
+    focusLock.handleKeyDown('tab');
+    expect(focusManager.currentId).toBe('modal-btn1');
   });

As per coding guidelines, "Tests must be real. ... Assert observable behavior or rendered output."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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('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);
expect(focusManager.currentId).toBe('modal-btn2');
focusLock.handleKeyDown('tab');
expect(focusManager.currentId).toBe('modal-btn1');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/focusLock.test.ts` around lines 34 - 42, Strengthen the
`handles tab navigation cycling inside trap` test by asserting
`focusManager.currentId` becomes `modal-btn2` after the first
`handleKeyDown('tab')`, then becomes `modal-btn1` after a second Tab to verify
wrapping. Also assert the background target `bg-btn` is never selected while the
trap remains active, while preserving the existing handled-result assertion.

Source: Coding guidelines

Comment on lines +44 to +52
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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert focus restoration after popTrap, and add a dedicated isolation test.

This test verifies isLocked becomes false after Escape, but does not verify that focus is restored to the widget that was focused before the trap (focusManager.currentId), which is an explicit PR objective ("Restore the previous focus highlight state").

Separately, no test in this file verifies that background widgets ('bg-btn') are unreachable via Tab while a trap is active — the core "isolate key event propagation" objective from the linked issue is untested.

✅ Proposed additions
   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);
+    expect(focusManager.currentId).toBe('bg-btn');
   });
+
+  test('isolates focus from background widgets while trapped', () => {
+    focusLock.pushTrap({ containerId: 'modal-1' }, [
+      { id: 'modal-btn1', tabIndex: 1, focusable: true },
+      { id: 'modal-btn2', tabIndex: 2, focusable: true },
+    ]);
+
+    for (let i = 0; i < 5; i++) {
+      focusLock.handleKeyDown('tab');
+      expect(focusManager.currentId).not.toBe('bg-btn');
+    }
+  });

As per PR objectives, "Restore the previous focus highlight state" and "Include unit tests covering focus isolation and related behavior."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
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);
expect(focusManager.currentId).toBe('bg-btn');
});
test('isolates focus from background widgets while trapped', () => {
focusLock.pushTrap({ containerId: 'modal-1' }, [
{ id: 'modal-btn1', tabIndex: 1, focusable: true },
{ id: 'modal-btn2', tabIndex: 2, focusable: true },
]);
for (let i = 0; i < 5; i++) {
focusLock.handleKeyDown('tab');
expect(focusManager.currentId).not.toBe('bg-btn');
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/focusLock.test.ts` around lines 44 - 52, Extend the Escape
test around focusLock.handleKeyDown to assert focusManager.currentId is restored
to the widget focused before pushTrap, not only that isLocked becomes false. Add
a dedicated test that activates a trap, attempts Tab navigation toward the
background widget 'bg-btn', and verifies that widget remains unreachable while
the trap is active.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core @termuijs/core type:feature +10 pts. New feature. type:testing +10 pts. Tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Implement Scoped Keyboard Focus Trap & Modal Management Subsystem

2 participants