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 toframe_focus_handle, so focus resolves to nothing (nodes.focusunset; the debug dump reportsgpui_focus: null; AT announces the whole window). - Mouse click into the text focuses the editor's presentation handle — which nothing paints
track_focusfor — and the same warning fires; the node'sfocusedflag only comes from thecontains_focusedfallback.
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.
Thanks for the detailed report. This is not fixed on current main (gpui-pre 0.3.6). The
RoleOverriderework only changed how the role is chosen; it did not change which element owns focus.What actually happens
crates/base/src/input/base/state.rs,cx.focus_handle().tab_stop(true)).frame_focus_handleis not a tab stop; the component only uses it forcontains_focusedso the focus ring stays on while a prefix/suffix button is focused.div().id("input-state").track_focus(..)insideInputBaseState. That element has an id but no role, so GPUI logs "has an id but no role" and never setsnodes.focus.TextInputnode (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 andtrack_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
TabStopMap::insertruns for every tracked handle regardless oftab_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.idwould no longer identify the node that carries role and value. That breaks lookups such aswindow.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.