Focus, Modals, and Disabled Nodes

Modals: declarative trapping

A modal is two declarations:

#![allow(unused)]
fn main() {
// 1. The modal subtree is a Trap focus scope.
Scene::focus_scope_with_policy(
    modal_id,
    "confirm-dialog",
    FocusScopePolicy::Trap,
    dialog_scene,
)

// 2. Escape (or a close action) sends a close message.
}

Trap makes Tab clamp at the scope boundary — focus physically cannot leave the modal via traversal. No imperative Tab interception, no key filtering in the modal's key_msg. When the modal closes, a refocus safety net returns focus to a sensible node (the runtime and the compose::trap_focus helper coordinate this).

This replaced an earlier imperative design where modals handled Tab in their own key path. The migration is described in docs/roadmap/00 (M1) and the semantics are pinned by end-to-end tests (trap_scope_clamps_focus_within_scope_under_runtime_tab).

Disabled nodes

#![allow(unused)]
fn main() {
Scene::text(id, "delete").focusable().disabled()
}

A .disabled() node:

  • is skipped by focus collection — Tab never lands on it
  • is skipped by activation routing — Enter/click on it sends nothing

It still renders (style it dim via with_style), so "present but unavailable" is a first-class state. Toggling .disabled() per-frame from Model is the intended way to express dynamic availability — e.g. a submit button disabled while a form is invalid.

Eligibility rules, precisely

Focus collection includes a node iff: it is .focusable() and not .disabled() and inside the visible scene. Scope policies then shape how traversal moves within that collected order. Effect::RequestFocus to a disabled or absent node is ignored.