diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 51bd65716..f58ce5f54 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -679,15 +679,10 @@ fn render_model_pane( cx: &mut Context| -> AnyElement { let composer = composer.clone(); - crate::material::accessible_clickable(div(), id, Role::Tab, label.clone(), cx) - .aria_selected(active) + crate::material::tab(id, label.clone(), active, cx) .flex_none() .size(px(if compact { 44. } else { 28. })) - .flex() - .items_center() - .justify_center() .rounded(px(6.)) - .cursor_pointer() .when(active, |s| s.bg(cx.theme().muted)) .hover(|s| s.bg(cx.theme().muted)) .tooltip(move |window, cx| { @@ -707,14 +702,22 @@ fn render_model_pane( .into_any_element() }; - let mut rail_col = v_flex().w_full().py_2().px_1p5().gap_1().child(rail_icon( - "rail-fav".into(), - crate::tr!("composer.favorites").into_owned().into(), - Icon::new(IconName::Star), - rail == PickerRail::Favorites, - PickerRail::Favorites, - cx, - )); + let mut rail_col = gpui_base::Tabs::new("model-provider-rail-tabs") + .flex() + .flex_col() + .aria_label(crate::tr!("composer.model_sources")) + .w_full() + .py_2() + .px_1p5() + .gap_1() + .child(rail_icon( + "rail-fav".into(), + crate::tr!("composer.favorites").into_owned().into(), + Icon::new(IconName::Star), + rail == PickerRail::Favorites, + PickerRail::Favorites, + cx, + )); // One entry per *enabled* native profile: every built-in plus any // user-created profiles whose switch is on. Each is its own rail. let profile_ids: Vec = { @@ -754,8 +757,6 @@ fn render_model_pane( } let rail = div() .id("model-provider-rail") - .role(Role::TabList) - .aria_label(crate::tr!("composer.model_sources")) .flex_none() .w(px(if compact { 56. } else { 44. })) .h_full() diff --git a/crates/ui/src/diff/view.rs b/crates/ui/src/diff/view.rs index 607695252..5857d92c0 100644 --- a/crates/ui/src/diff/view.rs +++ b/crates/ui/src/diff/view.rs @@ -695,15 +695,12 @@ impl DiffPanel { label: gpui::SharedString, is_active: bool, cx: &mut Context| - -> gpui::Stateful { - material::accessible_clickable(h_flex(), id, Role::Tab, label.clone(), cx) - .aria_selected(is_active) + -> gpui_base::Tab { + material::tab(id, label.clone(), is_active, cx) .h(px(28.)) .px_2p5() .gap_1p5() - .items_center() .rounded(material::radius_button()) - .cursor_pointer() .text_size(px(13.)) .font_medium() .when(is_active, |s| s.bg(tab_active)) @@ -714,10 +711,10 @@ impl DiffPanel { .child(label) }; - h_flex() - .id("right-panel-tabs") + gpui_base::Tabs::new("right-panel-tabs") + .flex() + .items_center() .debug_selector(|| "right-panel-tabs".into()) - .role(Role::TabList) .aria_label(crate::tr!("diff.panel_tabs")) .flex_none() .h(px(if hosts_caption { diff --git a/crates/ui/src/material.rs b/crates/ui/src/material.rs index f8993928c..a44744572 100644 --- a/crates/ui/src/material.rs +++ b/crates/ui/src/material.rs @@ -12,7 +12,7 @@ use gpui::{ Pixels, Rgba, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, div, linear_color_stop, linear_gradient, px, }; -use gpui_base::{StyledExt as _, Toggle, ToggleGroup, v_flex}; +use gpui_base::{StyledExt as _, Tab, Toggle, ToggleGroup, v_flex}; /// Height reserved beneath chat messages for hover-revealed actions. pub(crate) const CHAT_ACTION_ROW_HEIGHT: f32 = 24.; @@ -453,6 +453,23 @@ pub fn accessible_clickable( .aria_label(label) } +/// A gpui-base tab that can take keyboard focus. Callers style it and put +/// it in a [`gpui_base::Tabs`] list. +pub fn tab( + id: impl Into, + label: impl Into, + selected: bool, + cx: &App, +) -> Tab { + let ring = focus_ring(cx); + Tab::new(id) + .selected(selected) + .accessibility_label(label) + .tab_index(0) + .focus_visible(move |style| style.shadow(vec![ring])) + .cursor_pointer() +} + /// The keyboard focus ring of a clickable that is not a `Button`. fn focus_ring(cx: &App) -> BoxShadow { let ring = cx.theme().ring.opacity(if cx.theme().mode.is_dark() { diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 87948460f..bde898fca 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -831,38 +831,30 @@ impl SettingsPage { } else { cx.theme().muted_foreground }; - crate::material::accessible_clickable( - gpui_base::h_flex(), - section.id(), - Role::Tab, - label.clone(), - cx, - ) - .aria_selected(active) - .debug_selector(move || section.id().into()) - // Keep the hitbox, stable element id, and hover style on the - // same element. Splitting them across an outer clickable and - // an anonymous inner row leaves GPUI tracking two overlapping - // interaction regions, which makes hover paint stale or skip - // as the pointer crosses adjacent tabs. - .h(px(30.)) - .items_center() - .gap_2() - .px_2() - .rounded(px(6.)) - .cursor_pointer() - .when(active, |s| s.bg(cx.theme().list_active)) - .when(!active, |s| s.hover(|s| s.bg(cx.theme().sidebar_accent))) - .child(Icon::new(section.icon()).size_4().text_color(fg)) - .child( - div() - .text_size(px(13.)) - .when(active, |d| d.font_medium()) - .text_color(fg) - .child(label), - ) - .on_click(cx.listener(move |this, _, _, cx| this.select_section(section, cx))) - .into_any_element() + crate::material::tab(section.id(), label.clone(), active, cx) + .debug_selector(move || section.id().into()) + // Keep the hitbox, stable element id, and hover style on the + // same element. Splitting them across an outer clickable and + // an anonymous inner row leaves GPUI tracking two overlapping + // interaction regions, which makes hover paint stale or skip + // as the pointer crosses adjacent tabs. + .h(px(30.)) + .justify_start() + .gap_2() + .px_2() + .rounded(px(6.)) + .when(active, |s| s.bg(cx.theme().list_active)) + .when(!active, |s| s.hover(|s| s.bg(cx.theme().sidebar_accent))) + .child(Icon::new(section.icon()).size_4().text_color(fg)) + .child( + div() + .text_size(px(13.)) + .when(active, |d| d.font_medium()) + .text_color(fg) + .child(label), + ) + .on_click(cx.listener(move |this, _, _, cx| this.select_section(section, cx))) + .into_any_element() } fn group_caption(&self, group: SectionGroup, cx: &Context) -> AnyElement { @@ -980,9 +972,9 @@ impl SettingsPage { } fn render_nav(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let mut tabs = v_flex() - .id("settings-nav-tabs") - .role(Role::TabList) + let mut tabs = gpui_base::Tabs::new("settings-nav-tabs") + .flex() + .flex_col() .aria_label(crate::tr!("settings.title")) .flex_1() .min_h_0() diff --git a/crates/ui/src/terminal_drawer.rs b/crates/ui/src/terminal_drawer.rs index 9b7a4494e..b425e1c1e 100644 --- a/crates/ui/src/terminal_drawer.rs +++ b/crates/ui/src/terminal_drawer.rs @@ -1444,9 +1444,9 @@ impl Render for TerminalDrawer { self.marked_text = None; } - let mut tab_strip = h_flex() - .id("terminal-tab-list") - .role(Role::TabList) + let mut tab_strip = gpui_base::Tabs::new("terminal-tab-list") + .flex() + .items_center() .aria_label(crate::tr!("terminal.tabs")) .min_w_0() .gap(px(2.)) @@ -1457,61 +1457,54 @@ impl Render for TerminalDrawer { let close_id = id; let tab_label = crate::tr!("terminal.tab", label = label.clone()).into_owned(); tab_strip = tab_strip.child( - crate::material::accessible_clickable( - h_flex(), - ("terminal-tab", id), - Role::Tab, - tab_label, - cx, - ) - .aria_selected(selected) - .h(px(25.)) - .gap(px(2.)) - .px_2() - .rounded(material::radius_button()) - .cursor_pointer() - .bg(if selected { - cx.theme().list_active - } else { - cx.theme().background.opacity(0.) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.workspace_store - .update(cx, |store, _cx| store.activate_terminal(id)); - })) - .child( - div() - .max_w(px(92.)) - .overflow_hidden() - .text_ellipsis() - .text_size(px(11.)) - .text_color(if *exited || !selected { - cx.theme().muted_foreground - } else { - cx.theme().foreground - }) - .child(label.clone()), - ) - .when(*bell, |this| { - this.child( + crate::material::tab(("terminal-tab", id), tab_label, selected, cx) + .justify_start() + .h(px(25.)) + .gap(px(2.)) + .px_2() + .rounded(material::radius_button()) + .bg(if selected { + cx.theme().list_active + } else { + cx.theme().background.opacity(0.) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.workspace_store + .update(cx, |store, _cx| store.activate_terminal(id)); + })) + .child( div() + .max_w(px(92.)) + .overflow_hidden() + .text_ellipsis() .text_size(px(11.)) - .text_color(cx.theme().warning) - .child("●"), + .text_color(if *exited || !selected { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .child(label.clone()), ) - }) - .child( - Button::new(("terminal-tab-close", close_id)) - .ghost() - .compact() - .xsmall() - .icon(IconName::Close) - .tooltip(crate::tr!("terminal.close_tab")) - .on_click(cx.listener(move |this, _, _, cx| { - this.workspace_store - .update(cx, |store, cx| store.close_terminal(close_id, cx)); - })), - ), + .when(*bell, |this| { + this.child( + div() + .text_size(px(11.)) + .text_color(cx.theme().warning) + .child("●"), + ) + }) + .child( + Button::new(("terminal-tab-close", close_id)) + .ghost() + .compact() + .xsmall() + .icon(IconName::Close) + .tooltip(crate::tr!("terminal.close_tab")) + .on_click(cx.listener(move |this, _, _, cx| { + this.workspace_store + .update(cx, |store, cx| store.close_terminal(close_id, cx)); + })), + ), ); }