Conversation
`ListState` held a focus handle that was never a tab stop, so the keyboard
model the list already implements — SelectUp, SelectDown, Confirm, Cancel —
could only run once something else focused that handle. Tab never reached it,
and a consumer had to wrap the list in its own `track_focus` container,
re-declare `key_context("List")` and reimplement cursor movement to get
keyboard navigation back.
The handle is a tab stop now, and `List` takes `tab_stop` / `tab_index`
builders with the same meaning they have on `Button`. A searchable list keeps
its single landing place: `Focusable::focus_handle` already points Tab at the
search input, so the container suppresses its own stop rather than offering
one list two places to land.
Focus is also visible. While the list holds keyboard focus it draws the ring on
the selected row, or on itself when nothing is selected. The ring goes on the
inside edge: the row clips its own overflow and the virtual list clips the
viewport, so the usual outward band would be cropped away. Only the keyboard
raises it — clicking a row focuses the list, so the arrows continue from the
row the pointer chose, but a pointer click leaves no ring, as on `Button`.
The built-in popup lists — Select, Combobox, the completion and code action
menus — turn it off, since their trigger already carries the ring.
While in `render_list_item`: stop clearing a state the list does not own.
Every row was told `secondary_selected(mouse_right_clicked)`, so a delegate
that marked a row itself — a row whose own menu is open — had that mark wiped
on the next frame. The list now only sets the state it knows about.
`RowsCache::next(None)` also starts at the first row that exists instead of
row 0 of section 0, which is not a row when that section is empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Reported downstream (ai-desktop/pilot #169).
ListStatealready implements a keyboard model —SelectUp,SelectDown,Confirm,Cancel, bound in theListkey context — but the focus handle it tracks was created with a plaincx.focus_handle()and was never a tab stop. The bindings therefore only fired if something else happened to focus that handle; Tab never reached the list. A consumer wanting keyboard navigation had to wrapListin its owntrack_focuscontainer, re-declarekey_context("List")and reimplement roughly 30 lines of cursor movement on top of the list that already had it.Focus was also invisible:
is_focusedexisted but only for internal use, so nothing on screen said the keyboard was acting on the list.A second report from the same consumer:
render_list_itemtold every rowsecondary_selected(mouse_right_clicked). A delegate that set that state itself — a row whose own "…" menu is open — had it cleared on the next frame, and had to work around it by OR-ing inside its ownSelectableimplementation.What changed
1. The list is a tab stop.
ListState::newcreates its handle withtab_stop(true), andListgainstab_stop(bool)/tab_index(isize)builders with the same names and meaning they have onButton. Both are applied to the handle instance that is tracked, sinceFocusHandle's tab state is per instance.A searchable list keeps exactly one landing place.
Focusable::focus_handlealready points Tab at the search input, so the container suppresses its own stop whilesearchableis true — otherwise one list would offer two places to land, and Tab would stop twice on the same widget.2. Focus is visible, on the selected row. While the list holds keyboard focus it draws a focus ring on the selected row; with nothing selected there is no row to mark, so the list itself carries it (and
Downstarts at the first row from there).Where the ring goes: inside the row, not around it. The row wrapper sets
overflow_hiddenand the virtual list clips the viewport, sofocus_ring_style's outward band would be cropped away — the Design Guides' own warning that "a clipped region also clips an outward focus ring". Rather than reserve 3px of row height for a band that selection backgrounds would then not reach, this addsstyled::inset_focus_ring, which spends the same width and ink on the inside edge. It followsfocus_ring_style's policy forTheme::focus_ring == false: no band, just the tinted hairline.Pointer vs. keyboard: clicking a row focuses the list, which is what lets the arrow keys continue from the row the pointer chose. That focus draws no ring — like
Button, the list shows one only once the keyboard is the input in use (window.last_input_was_keyboard()), rather than suppressing focus on mouse down and losing keyboard continuity.Built-in popups opt out: Select, Combobox, the completion menu and the code action menu pass
focus_ring(false). Their popup is the keyboard's only target and their trigger already wears the ring, so a second ring inside would be noise — and it would partly undo the look #3108 just cleaned up.ListimplementsFocusableExt, so any consumer can do the same.3. The list no longer clears state it does not own.
render_list_itemsetssecondary_selected(true)only when the row is the right-clicked one, instead of pushingfalseonto every other row. The list sets what it knows; it does not clear what it does not know.4.
RowsCache::next(None)now starts at the first row that exists rather than row 0 of section 0, which is not a row when that section is empty.Tests
New
keyboard_testsincrates/component/src/list/list.rs; each was confirmed to fail before the change.tab_reaches_the_list_and_the_keyboard_drives_it—focus_nextlands on the list, thendown/down/upmove the selection,enterconfirms,escapeclears.the_keyboard_focus_ring_follows_the_selected_row— no ring before the keyboard is used; afterdownthe ring is on the selected row (24px tall, the row height) and not on the container; afterescapeit moves to the container.a_list_that_is_not_a_tab_stop_is_skipped— withtab_stop(false), Tab goes past the list to the next control.a_searchable_list_puts_its_tab_stop_on_the_search_input— Tab lands on the search input and not on the container.the_list_leaves_a_row_the_delegate_marked_secondary_selected— a probe item records what the list sets; a row the delegate marked stays marked.cargo test -p gpui-componentis green (529 lib tests + integration), as arecargo clippy -p gpui-component -p gpui-component-story --all-targets -- --deny warnings,cargo fmt --checkandtypos.Documentation
website/component/list.mdandwebsite/zh-CN/component/list.mdgain a "Keyboard Access" / "键盘可达" section: the key table (Tab, ↑↓, Enter, Esc), where the ring is drawn, how a searchable list is reached, and thetab_stop/tab_index/focus_ringbuilders.No breaking changes:
ListItemis untouched, and the newListbuilders are additive.🤖 Generated with Claude Code