#3183

Input: focused frame handle has no accessibility node — Tab/click focus resolves to nothing (Role::TextInput not bound to frame_focus_handle)

Opennoosxeopened 1mo ago

Observed at gpui-component rev 94a313a (v0.6.0 line). Possibly reworked by v0.6.6 (.role(...) became RoleOverride in input.rs) — see question at the end.

Input renders a focusable frame whose handle is distinct from the text editor's:

let frame_focus_handle = window
    .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| cx.focus_handle())
    .read(cx)
    .clone();
...
BaseInput::new(("input", state.entity_id()))
    .focused(focused)
    .track_focus(&frame_focus_handle)   // ← Tab lands here
    .role(accessibility_role)           // painted on the builder…

Live observations (headless sway, Linux/accesskit-unix):

  • Tab moves focus to the frame handle, and gpui logs "focused element (FocusId(..)) has no accessibility node (it has an id but no role)" — i.e. despite .role(accessibility_role) on the builder, the painted node either isn't produced for the frame or isn't bound to frame_focus_handle, so focus resolves to nothing (nodes.focus unset; the debug dump reports gpui_focus: null; AT announces the whole window).
  • Mouse click into the text focuses the editor's presentation handle — which nothing paints track_focus for — and the same warning fires; the node's focused flag only comes from the contains_focused fallback.

So a text input in the tab chain behaves as a "focus without node" gap: keyboard focus lands, but no accessibility node represents it.

Ask: make the frame's painted node bound to frame_focus_handle carry the input role/name (so focus maps), or otherwise expose a node for whichever handle receives focus. If this is already fixed on main / in v0.6.6 by the RoleOverride rework, a pointer to the commit would be appreciated and we'll pick it up at our next rev bump.

Activity

1
huacnlee1mo ago

Thanks for the detailed report. This is not fixed on current main (gpui-pre 0.3.6). The RoleOverride rework only changed how the role is chosen; it did not change which element owns focus.

What actually happens

  • Tab and a mouse click both focus the editor's handle, not the frame's. The editor handle is the only tab stop (crates/base/src/input/base/state.rs, cx.focus_handle().tab_stop(true)). frame_focus_handle is not a tab stop; the component only uses it for contains_focused so the focus ring stays on while a prefix/suffix button is focused.
  • The editor handle is tracked by div().id("input-state").track_focus(..) inside InputBaseState. That element has an id but no role, so GPUI logs "has an id but no role" and never sets nodes.focus.
  • The TextInput node (role, label, value, placeholder, Focus/SetValue actions) is on the outer frame. GPUI only binds focus to a node when the same element has both the role and track_focus (Interactivity::prepaint → a11y.set_focusable / set_focus). It has no way to say "this node represents a focus handle tracked by a descendant", so focus never reaches the frame's node.

Why the obvious fixes don't work

  • #3246 had the frame track the editor's handle too. TabStopMap::insert runs for every tracked handle regardless of tab_stop, so the same handle was inserted twice and reverse traversal (Shift-Tab) got stuck. #3253 reverted it; #3254 restored only the guarded Focus/SetValue actions.
  • Moving the node onto the editor element would bind focus correctly, but then the Input's public id would no longer identify the node that carries role and value. That breaks lookups such as window.find("my-input").value() in existing application tests.

Direction

A proper fix needs GPUI to support focus delegation: letting an element's accessibility node register as the node for a focus handle without inserting that handle into the tab-stop map. With that in place, the Input frame keeps its id, role and value, focus maps to it, and tab order is unchanged. We'll pick this up once GPUI can express it. Until then this issue stays open as the tracking issue.