Skip to content

list: Make the list a tab stop and draw focus on the selected row - #3127

Open
huacnlee wants to merge 1 commit into
mainfrom
list-tab-stop
Open

huacnlee wants to merge 1 commit into
mainfrom
list-tab-stop

Conversation

@huacnlee

Copy link
Copy Markdown
Member

The problem

Reported downstream (ai-desktop/pilot #169).

ListState already implements a keyboard model — SelectUp, SelectDown, Confirm, Cancel, bound in the List key context — but the focus handle it tracks was created with a plain cx.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 wrap List in its own track_focus container, re-declare key_context("List") and reimplement roughly 30 lines of cursor movement on top of the list that already had it.

Focus was also invisible: is_focused existed 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_item told every row secondary_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 own Selectable implementation.

What changed

1. The list is a tab stop. ListState::new creates its handle with tab_stop(true), and List gains tab_stop(bool) / tab_index(isize) builders with the same names and meaning they have on Button. Both are applied to the handle instance that is tracked, since FocusHandle's tab state is per instance.

A searchable list keeps exactly one landing place. Focusable::focus_handle already points Tab at the search input, so the container suppresses its own stop while searchable is 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 Down starts at the first row from there).

Where the ring goes: inside the row, not around it. The row wrapper sets overflow_hidden and the virtual list clips the viewport, so focus_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 adds styled::inset_focus_ring, which spends the same width and ink on the inside edge. It follows focus_ring_style's policy for Theme::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. List implements FocusableExt, so any consumer can do the same.

3. The list no longer clears state it does not own. render_list_item sets secondary_selected(true) only when the row is the right-clicked one, instead of pushing false onto 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_tests in crates/component/src/list/list.rs; each was confirmed to fail before the change.

  • tab_reaches_the_list_and_the_keyboard_drives_itfocus_next lands on the list, then down/down/up move the selection, enter confirms, escape clears.
  • the_keyboard_focus_ring_follows_the_selected_row — no ring before the keyboard is used; after down the ring is on the selected row (24px tall, the row height) and not on the container; after escape it moves to the container.
  • a_list_that_is_not_a_tab_stop_is_skipped — with tab_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-component is green (529 lib tests + integration), as are cargo clippy -p gpui-component -p gpui-component-story --all-targets -- --deny warnings, cargo fmt --check and typos.

Documentation

website/component/list.md and website/zh-CN/component/list.md gain a "Keyboard Access" / "键盘可达" section: the key table (Tab, ↑↓, Enter, Esc), where the ring is drawn, how a searchable list is reached, and the tab_stop / tab_index / focus_ring builders.

No breaking changes: ListItem is untouched, and the new List builders are additive.

🤖 Generated with Claude Code

`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant