ui: Wire up tab indices within buttons (#35368)

Finn Evers created

This change adds the current tab index functionality to buttons and
implements a proof of concept for the new welcome page.

Primarily blocked on https://github.com/zed-industries/zed/pull/34804,
secondarily on https://github.com/zed-industries/zed/pull/35075 so we
can ensure navigation always works as intended.

Another thing to consider here is whether we want to assign the tab
order more implicitly / "automatically" based on the current layout
ordering. This would generally enable us to add a default order to
focusable elements if we want this. See [the
specification](https://html.spec.whatwg.org/multipage/interaction.html#flattened-tabindex-ordered-focus-navigation-scope)
on some more context on how the web usually handles this for focusable
elements.

Release Notes:

- N/A

Change summary

crates/onboarding/Cargo.toml                     |  4 +-
crates/onboarding/src/welcome.rs                 | 22 ++++++++++++++++-
crates/ui/src/components/button/button.rs        |  5 ++++
crates/ui/src/components/button/button_like.rs   | 18 +++++++++++++-
crates/ui/src/components/button/icon_button.rs   |  5 ++++
crates/ui/src/components/button/toggle_button.rs |  5 ++++
6 files changed, 53 insertions(+), 6 deletions(-)

Detailed changes

crates/onboarding/Cargo.toml 🔗

@@ -15,13 +15,13 @@ path = "src/onboarding.rs"
 default = []
 
 [dependencies]
-anyhow.workspace = true
 ai_onboarding.workspace = true
+anyhow.workspace = true
 client.workspace = true
 command_palette_hooks.workspace = true
 component.workspace = true
-documented.workspace = true
 db.workspace = true
+documented.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true

crates/onboarding/src/welcome.rs 🔗

@@ -2,6 +2,7 @@ use gpui::{
     Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
     NoAction, ParentElement, Render, Styled, Window, actions,
 };
+use menu::{SelectNext, SelectPrevious};
 use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
 use workspace::{
     NewFile, Open, WorkspaceId,
@@ -124,6 +125,7 @@ impl SectionEntry {
         cx: &App,
     ) -> impl IntoElement {
         ButtonLike::new(("onboarding-button-id", button_index))
+            .tab_index(button_index as isize)
             .full_width()
             .size(ButtonSize::Medium)
             .child(
@@ -153,10 +155,23 @@ pub struct WelcomePage {
     focus_handle: FocusHandle,
 }
 
+impl WelcomePage {
+    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next();
+        cx.notify();
+    }
+
+    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_prev();
+        cx.notify();
+    }
+}
+
 impl Render for WelcomePage {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let (first_section, second_entries) = CONTENT;
+        let (first_section, second_section) = CONTENT;
         let first_section_entries = first_section.entries.len();
+        let last_index = first_section_entries + second_section.entries.len();
 
         h_flex()
             .size_full()
@@ -165,6 +180,8 @@ impl Render for WelcomePage {
             .bg(cx.theme().colors().editor_background)
             .key_context("Welcome")
             .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_next))
             .child(
                 h_flex()
                     .px_12()
@@ -202,7 +219,7 @@ impl Render for WelcomePage {
                                         window,
                                         cx,
                                     ))
-                                    .child(second_entries.render(
+                                    .child(second_section.render(
                                         first_section_entries,
                                         &self.focus_handle,
                                         window,
@@ -220,6 +237,7 @@ impl Render for WelcomePage {
                                             .border_dashed()
                                             .child(
                                                     Button::new("welcome-exit", "Return to Setup")
+                                                        .tab_index(last_index as isize)
                                                         .full_width()
                                                         .label_size(LabelSize::XSmall)
                                                         .on_click(|_, window, cx| {

crates/ui/src/components/button/button.rs 🔗

@@ -393,6 +393,11 @@ impl ButtonCommon for Button {
         self
     }
 
+    fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
+        self.base = self.base.tab_index(tab_index);
+        self
+    }
+
     fn layer(mut self, elevation: ElevationIndex) -> Self {
         self.base = self.base.layer(elevation);
         self

crates/ui/src/components/button/button_like.rs 🔗

@@ -1,7 +1,7 @@
 use documented::Documented;
 use gpui::{
     AnyElement, AnyView, ClickEvent, CursorStyle, DefiniteLength, Hsla, MouseButton,
-    MouseDownEvent, MouseUpEvent, Rems, relative, transparent_black,
+    MouseDownEvent, MouseUpEvent, Rems, StyleRefinement, relative, transparent_black,
 };
 use smallvec::SmallVec;
 
@@ -37,6 +37,8 @@ pub trait ButtonCommon: Clickable + Disableable {
     /// exceptions might a scroll bar, or a slider.
     fn tooltip(self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self;
 
+    fn tab_index(self, tab_index: impl Into<isize>) -> Self;
+
     fn layer(self, elevation: ElevationIndex) -> Self;
 }
 
@@ -393,6 +395,7 @@ pub struct ButtonLike {
     pub(super) width: Option<DefiniteLength>,
     pub(super) height: Option<DefiniteLength>,
     pub(super) layer: Option<ElevationIndex>,
+    tab_index: Option<isize>,
     size: ButtonSize,
     rounding: Option<ButtonLikeRounding>,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
@@ -421,6 +424,7 @@ impl ButtonLike {
             on_click: None,
             on_right_click: None,
             layer: None,
+            tab_index: None,
         }
     }
 
@@ -525,6 +529,11 @@ impl ButtonCommon for ButtonLike {
         self
     }
 
+    fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
+        self.tab_index = Some(tab_index.into());
+        self
+    }
+
     fn layer(mut self, elevation: ElevationIndex) -> Self {
         self.layer = Some(elevation);
         self
@@ -554,6 +563,7 @@ impl RenderOnce for ButtonLike {
         self.base
             .h_flex()
             .id(self.id.clone())
+            .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
             .font_ui(cx)
             .group("")
             .flex_none()
@@ -591,8 +601,12 @@ impl RenderOnce for ButtonLike {
                 }
             })
             .when(!self.disabled, |this| {
+                let hovered_style = style.hovered(self.layer, cx);
+                let focus_color =
+                    |refinement: StyleRefinement| refinement.bg(hovered_style.background);
                 this.cursor(self.cursor_style)
-                    .hover(|hover| hover.bg(style.hovered(self.layer, cx).background))
+                    .hover(focus_color)
+                    .focus(focus_color)
                     .active(|active| active.bg(style.active(cx).background))
             })
             .when_some(

crates/ui/src/components/button/icon_button.rs 🔗

@@ -164,6 +164,11 @@ impl ButtonCommon for IconButton {
         self
     }
 
+    fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
+        self.base = self.base.tab_index(tab_index);
+        self
+    }
+
     fn layer(mut self, elevation: ElevationIndex) -> Self {
         self.base = self.base.layer(elevation);
         self

crates/ui/src/components/button/toggle_button.rs 🔗

@@ -121,6 +121,11 @@ impl ButtonCommon for ToggleButton {
         self
     }
 
+    fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
+        self.base = self.base.tab_index(tab_index);
+        self
+    }
+
     fn layer(mut self, elevation: ElevationIndex) -> Self {
         self.base = self.base.layer(elevation);
         self