[GH-ISSUE #638] Proposal: <focus-scope> element with scope-aware keyboard hooks #169

Closed
opened 2026-03-02 23:45:02 +03:00 by kerem · 1 comment
Owner

Originally created by @cevr on GitHub (Feb 7, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/638

Problem

There's currently no way to isolate keyboard events to a subtree. When an overlay/modal is open, keystrokes leak to handlers underneath. There's also no built-in Tab/Shift+Tab focus cycling within a container.

Real example: in a project using opentui, a cross-references popup renders over a Bible reader — but j/k keypresses still scroll the reader behind it.

Proposal: <focus-scope>

A declarative container element (like Radix FocusScope or React Aria's FocusScope) that provides:

  1. Keyboard isolation — keys routed to scope subscribers, stopPropagation called automatically for trapped scopes
  2. Tab navigation — Tab/Shift+Tab cycles through focusable children
  3. Scope-aware useKeyboard — detects nearest scope via a ref, no wrapper components needed
  4. Focus save/restore — on activate, saves current focus; on deactivate, restores it

Usage

const Modal = () => {
  let ref!: BoxRenderable
  useKeyboard((key) => {
    if (key.name === 'escape') close()
  }, { ref: () => ref })

  return (
    <focus_scope>
      <box ref={ref}>
        {content}
      </box>
    </focus_scope>
  )
}

// Global handler — no ref, works when no scope is active
useKeyboard((key) => console.log(key.name))

Props

Prop Type Default Description
trapFocus boolean true Tab wraps within scope, keys isolated
autoFocus boolean true Focus first focusable child on mount

Hook API

useKeyboard(callback, options?)

Option Type Description
ref () => Renderable Walks tree to find nearest scope. Omit for global.
release boolean Also receive key release events

Design

Keyboard interception via prependListener

EventEmitter.prependListener puts a handler at index 0 of the listener array. emitWithPriority() hits it first, it calls stopPropagation(), and the loop exits — no other handlers fire.

  • Zero overhead when no scope is active
  • Nested scopes stack naturally (inner scope's prependListener goes before outer → fires first)
  • Ctrl+C always passes through

Scope detection: ref-based tree walking

Consumer passes { ref: () => ref } where ref points to a renderable inside the scope. At onMount time, closestFocusScope() walks up the .parent chain using instanceof FocusScopeRenderable. If found, subscribes to scope's local dispatch (.on()/.off()). If not found, falls back to global keyboard handler.

Architecture

  • FocusScopeRenderable extends BoxRenderable (transparent container)
  • Focus traversal utilities in lib/focus-traversal.ts (internal, not exported from public API)
  • Focus save/restore built into activate/deactivate lifecycle

Implementation

PR: #646

76 tests across core (49) and solid (17).

Originally created by @cevr on GitHub (Feb 7, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/638 ## Problem There's currently no way to isolate keyboard events to a subtree. When an overlay/modal is open, keystrokes leak to handlers underneath. There's also no built-in Tab/Shift+Tab focus cycling within a container. Real example: in a project using opentui, a cross-references popup renders over a Bible reader — but `j`/`k` keypresses still scroll the reader behind it. ## Proposal: `<focus-scope>` A declarative container element (like [Radix FocusScope](https://www.radix-ui.com/primitives/utilities/focus-scope) or React Aria's `FocusScope`) that provides: 1. **Keyboard isolation** — keys routed to scope subscribers, `stopPropagation` called automatically for trapped scopes 2. **Tab navigation** — Tab/Shift+Tab cycles through focusable children 3. **Scope-aware `useKeyboard`** — detects nearest scope via a ref, no wrapper components needed 4. **Focus save/restore** — on activate, saves current focus; on deactivate, restores it ### Usage ```tsx const Modal = () => { let ref!: BoxRenderable useKeyboard((key) => { if (key.name === 'escape') close() }, { ref: () => ref }) return ( <focus_scope> <box ref={ref}> {content} </box> </focus_scope> ) } // Global handler — no ref, works when no scope is active useKeyboard((key) => console.log(key.name)) ``` ### Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `trapFocus` | `boolean` | `true` | Tab wraps within scope, keys isolated | | `autoFocus` | `boolean` | `true` | Focus first focusable child on mount | ### Hook API **`useKeyboard(callback, options?)`** | Option | Type | Description | |--------|------|-------------| | `ref` | `() => Renderable` | Walks tree to find nearest scope. Omit for global. | | `release` | `boolean` | Also receive key release events | ## Design ### Keyboard interception via `prependListener` `EventEmitter.prependListener` puts a handler at index 0 of the listener array. `emitWithPriority()` hits it first, it calls `stopPropagation()`, and the loop exits — no other handlers fire. - Zero overhead when no scope is active - Nested scopes stack naturally (inner scope's `prependListener` goes before outer → fires first) - Ctrl+C always passes through ### Scope detection: ref-based tree walking Consumer passes `{ ref: () => ref }` where `ref` points to a renderable inside the scope. At `onMount` time, `closestFocusScope()` walks up the `.parent` chain using `instanceof FocusScopeRenderable`. If found, subscribes to scope's local dispatch (`.on()`/`.off()`). If not found, falls back to global keyboard handler. ### Architecture - `FocusScopeRenderable` extends `BoxRenderable` (transparent container) - Focus traversal utilities in `lib/focus-traversal.ts` (internal, not exported from public API) - Focus save/restore built into activate/deactivate lifecycle ## Implementation PR: #646 76 tests across core (49) and solid (17).
kerem closed this issue 2026-03-02 23:45:02 +03:00
Author
Owner

@cevr commented on GitHub (Feb 8, 2026):

Implementation update from PR #646:

The final implementation intentionally diverged from the original <focus-scope> proposal to reduce API surface.

What shipped on the branch:

  • No dedicated <focus-scope> / <focus_scope> component API.
  • Scope behavior moved to generic renderables via props:
    • trapFocus (scope + Tab/Shift+Tab cycle + key isolation)
    • autoFocus (optional mount autofocus)
  • Keyboard dispatch now bubbles from currentFocusedRenderable up parent chain (processKeyEvent / processKeyReleaseEvent / processPasteEvent), so scope semantics are tree-native.
  • useKeyboard in React/Solid now supports { ref } to resolve nearest scoped ancestor; without ref it remains global.

Net effect: same user-facing capability (subtree keyboard isolation, scoped tab cycle, focus save/restore), but with less framework/component surface and less migration churn.

<!-- gh-comment-id:3867433595 --> @cevr commented on GitHub (Feb 8, 2026): Implementation update from PR #646: The final implementation intentionally diverged from the original `<focus-scope>` proposal to reduce API surface. What shipped on the branch: - No dedicated `<focus-scope>` / `<focus_scope>` component API. - Scope behavior moved to generic renderables via props: - `trapFocus` (scope + Tab/Shift+Tab cycle + key isolation) - `autoFocus` (optional mount autofocus) - Keyboard dispatch now bubbles from `currentFocusedRenderable` up parent chain (`processKeyEvent` / `processKeyReleaseEvent` / `processPasteEvent`), so scope semantics are tree-native. - `useKeyboard` in React/Solid now supports `{ ref }` to resolve nearest scoped ancestor; without ref it remains global. Net effect: same user-facing capability (subtree keyboard isolation, scoped tab cycle, focus save/restore), but with less framework/component surface and less migration churn.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#169
No description provided.