Rework `ListItem` and `ListHeader` to use slot-based APIs (#3635)

Marshall Bowers created

This PR reworks the `ListItem` and `ListHeader` components to use
slot-based APIs, making them less opinionated about their contents.

Splitting this out of the collab UI styling PR so we can land it to
avoid conflicts.

Co-authored-by: Nate <nate@zed.dev>

Release Notes:

- N/A

Change summary

crates/collab_ui2/src/collab_panel.rs             |  28 +-
crates/picker2/src/picker2.rs                     |   1 
crates/ui2/src/components/context_menu.rs         |   5 
crates/ui2/src/components/list/list_header.rs     |  67 ++++--
crates/ui2/src/components/list/list_item.rs       | 161 +++++++++++-----
crates/ui2/src/components/list/list_separator.rs  |   6 
crates/ui2/src/components/list/list_sub_header.rs |   8 
crates/ui2/src/components/stories/list_header.rs  |  12 
crates/ui2/src/components/stories/list_item.rs    |  72 ++++++
crates/ui2/src/styled_ext.rs                      |  16 +
10 files changed, 267 insertions(+), 109 deletions(-)

Detailed changes

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -1156,7 +1156,7 @@ impl CollabPanel {
         let tooltip = format!("Follow {}", user.github_login);
 
         ListItem::new(SharedString::from(user.github_login.clone()))
-            .left_child(Avatar::new(user.avatar_uri.clone()))
+            .start_slot(Avatar::new(user.avatar_uri.clone()))
             .child(
                 h_stack()
                     .w_full()
@@ -1212,7 +1212,7 @@ impl CollabPanel {
                         .detach_and_log_err(cx);
                 });
             }))
-            .left_child(render_tree_branch(is_last, cx))
+            .start_slot(render_tree_branch(is_last, cx))
             .child(IconButton::new(0, Icon::Folder))
             .child(Label::new(project_name.clone()))
             .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
@@ -1305,7 +1305,7 @@ impl CollabPanel {
         let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
 
         ListItem::new(("screen", id))
-            .left_child(render_tree_branch(is_last, cx))
+            .start_slot(render_tree_branch(is_last, cx))
             .child(IconButton::new(0, Icon::Screen))
             .child(Label::new("Screen"))
             .when_some(peer_id, |this, _| {
@@ -1372,7 +1372,7 @@ impl CollabPanel {
             .on_click(cx.listener(move |this, _, cx| {
                 this.open_channel_notes(channel_id, cx);
             }))
-            .left_child(render_tree_branch(false, cx))
+            .start_slot(render_tree_branch(false, cx))
             .child(IconButton::new(0, Icon::File))
             .child(Label::new("notes"))
             .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
@@ -1387,7 +1387,7 @@ impl CollabPanel {
             .on_click(cx.listener(move |this, _, cx| {
                 this.join_channel_chat(channel_id, cx);
             }))
-            .left_child(render_tree_branch(true, cx))
+            .start_slot(render_tree_branch(true, cx))
             .child(IconButton::new(0, Icon::MessageBubbles))
             .child(Label::new("chat"))
             .tooltip(move |cx| Tooltip::text("Open Chat", cx))
@@ -2318,7 +2318,7 @@ impl CollabPanel {
                 } else {
                     el.child(
                         ListHeader::new(text)
-                            .when_some(button, |el, button| el.meta(button))
+                            .when_some(button, |el, button| el.end_slot(button))
                             .selected(is_selected),
                     )
                 }
@@ -2381,7 +2381,7 @@ impl CollabPanel {
                             )
                         }),
                 )
-                .left_child(
+                .start_slot(
                     // todo!() handle contacts with no avatar
                     Avatar::new(contact.user.avatar_uri.clone())
                         .availability_indicator(if online { Some(!busy) } else { None }),
@@ -2460,7 +2460,7 @@ impl CollabPanel {
                     .child(Label::new(github_login.clone()))
                     .child(h_stack().children(controls)),
             )
-            .left_avatar(user.avatar_uri.clone())
+            .start_slot::<Avatar>(user.avatar_uri.clone().map(|avatar| Avatar::new(avatar)))
     }
 
     fn render_contact_placeholder(
@@ -2568,7 +2568,11 @@ impl CollabPanel {
                 ListItem::new(channel_id as usize)
                     .indent_level(depth)
                     .indent_step_size(cx.rem_size() * 14.0 / 16.0) // @todo()! @nate this is to  step over the disclosure toggle
-                    .left_icon(if is_public { Icon::Public } else { Icon::Hash })
+                    .start_slot(
+                        IconElement::new(if is_public { Icon::Public } else { Icon::Hash })
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .selected(is_selected || is_active)
                     .child(
                         h_stack()
@@ -2962,7 +2966,11 @@ impl CollabPanel {
         let item = ListItem::new("channel-editor")
             .inset(false)
             .indent_level(depth)
-            .left_icon(Icon::Hash);
+            .start_slot(
+                IconElement::new(Icon::Hash)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            );
 
         if let Some(pending_name) = self
             .channel_editing_state

crates/picker2/src/picker2.rs 🔗

@@ -271,7 +271,6 @@ impl<D: PickerDelegate> Render for Picker<D> {
                                 },
                             )
                             .track_scroll(self.scroll_handle.clone())
-                            .p_1()
                         )
                         .max_h_72()
                         .overflow_hidden(),

crates/ui2/src/components/context_menu.rs 🔗

@@ -255,6 +255,9 @@ impl Render for ContextMenu {
                                 };
 
                                 ListItem::new(label.clone())
+                                    .inset(true)
+                                    .selected(Some(ix) == self.selected_index)
+                                    .on_click(move |_, cx| handler(cx))
                                     .child(
                                         h_stack()
                                             .w_full()
@@ -265,8 +268,6 @@ impl Render for ContextMenu {
                                                     .map(|binding| div().ml_1().child(binding))
                                             })),
                                     )
-                                    .selected(Some(ix) == self.selected_index)
-                                    .on_click(move |_, cx| handler(cx))
                                     .into_any_element()
                             }
                         },

crates/ui2/src/components/list/list_header.rs 🔗

@@ -1,12 +1,18 @@
-use crate::{h_stack, prelude::*, Disclosure, Icon, IconElement, IconSize, Label};
+use crate::{h_stack, prelude::*, Disclosure, Label};
 use gpui::{AnyElement, ClickEvent, Div};
-use smallvec::SmallVec;
 
 #[derive(IntoElement)]
 pub struct ListHeader {
+    /// The label of the header.
     label: SharedString,
-    left_icon: Option<Icon>,
-    meta: SmallVec<[AnyElement; 2]>,
+    /// A slot for content that appears before the label, like an icon or avatar.
+    start_slot: Option<AnyElement>,
+    /// A slot for content that appears after the label, usually on the other side of the header.
+    /// This might be a button, a disclosure arrow, a face pile, etc.
+    end_slot: Option<AnyElement>,
+    /// A slot for content that appears on hover after the label
+    /// It will obscure the `end_slot` when visible.
+    end_hover_slot: Option<AnyElement>,
     toggle: Option<bool>,
     on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
     inset: bool,
@@ -17,8 +23,9 @@ impl ListHeader {
     pub fn new(label: impl Into<SharedString>) -> Self {
         Self {
             label: label.into(),
-            left_icon: None,
-            meta: SmallVec::new(),
+            start_slot: None,
+            end_slot: None,
+            end_hover_slot: None,
             inset: false,
             toggle: None,
             on_toggle: None,
@@ -39,13 +46,23 @@ impl ListHeader {
         self
     }
 
-    pub fn left_icon(mut self, left_icon: impl Into<Option<Icon>>) -> Self {
-        self.left_icon = left_icon.into();
+    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
+        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
         self
     }
 
-    pub fn meta(mut self, meta: impl IntoElement) -> Self {
-        self.meta.push(meta.into_any_element());
+    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
+        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
+        self
+    }
+
+    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
+        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
+        self
+    }
+
+    pub fn inset(mut self, inset: bool) -> Self {
+        self.inset = inset;
         self
     }
 }
@@ -61,9 +78,9 @@ impl RenderOnce for ListHeader {
     type Rendered = Div;
 
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        h_stack().w_full().relative().child(
+        h_stack().w_full().relative().group("list_header").child(
             div()
-                .h_5()
+                .h_7()
                 .when(self.inset, |this| this.px_2())
                 .when(self.selected, |this| {
                     this.bg(cx.theme().colors().ghost_element_selected)
@@ -77,24 +94,30 @@ impl RenderOnce for ListHeader {
                 .child(
                     h_stack()
                         .gap_1()
+                        .children(
+                            self.toggle
+                                .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)),
+                        )
                         .child(
                             div()
                                 .flex()
                                 .gap_1()
                                 .items_center()
-                                .children(self.left_icon.map(|i| {
-                                    IconElement::new(i)
-                                        .color(Color::Muted)
-                                        .size(IconSize::Small)
-                                }))
+                                .children(self.start_slot)
                                 .child(Label::new(self.label.clone()).color(Color::Muted)),
-                        )
-                        .children(
-                            self.toggle
-                                .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)),
                         ),
                 )
-                .child(h_stack().gap_2().items_center().children(self.meta)),
+                .child(h_stack().children(self.end_slot))
+                .when_some(self.end_hover_slot, |this, end_hover_slot| {
+                    this.child(
+                        div()
+                            .invisible()
+                            .group_hover("list_header", |this| this.visible())
+                            .absolute()
+                            .right_0()
+                            .child(end_hover_slot),
+                    )
+                }),
         )
     }
 }

crates/ui2/src/components/list/list_item.rs 🔗

@@ -1,7 +1,6 @@
-use crate::{prelude::*, Avatar, Disclosure, Icon, IconElement, IconSize};
+use crate::{prelude::*, Disclosure};
 use gpui::{
-    px, AnyElement, AnyView, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels,
-    Stateful,
+    px, AnyElement, AnyView, ClickEvent, Div, MouseButton, MouseDownEvent, Pixels, Stateful,
 };
 use smallvec::SmallVec;
 
@@ -9,11 +8,16 @@ use smallvec::SmallVec;
 pub struct ListItem {
     id: ElementId,
     selected: bool,
-    // TODO: Reintroduce this
-    // disclosure_control_style: DisclosureControlVisibility,
     indent_level: usize,
     indent_step_size: Pixels,
-    left_slot: Option<AnyElement>,
+    /// A slot for content that appears before the children, like an icon or avatar.
+    start_slot: Option<AnyElement>,
+    /// A slot for content that appears after the children, usually on the other side of the header.
+    /// This might be a button, a disclosure arrow, a face pile, etc.
+    end_slot: Option<AnyElement>,
+    /// A slot for content that appears on hover after the children
+    /// It will obscure the `end_slot` when visible.
+    end_hover_slot: Option<AnyElement>,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
@@ -30,7 +34,9 @@ impl ListItem {
             selected: false,
             indent_level: 0,
             indent_step_size: px(12.),
-            left_slot: None,
+            start_slot: None,
+            end_slot: None,
+            end_hover_slot: None,
             toggle: None,
             inset: false,
             on_click: None,
@@ -87,23 +93,18 @@ impl ListItem {
         self
     }
 
-    pub fn left_child(mut self, left_content: impl IntoElement) -> Self {
-        self.left_slot = Some(left_content.into_any_element());
+    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
+        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
         self
     }
 
-    pub fn left_icon(mut self, left_icon: Icon) -> Self {
-        self.left_slot = Some(
-            IconElement::new(left_icon)
-                .size(IconSize::Small)
-                .color(Color::Muted)
-                .into_any_element(),
-        );
+    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
+        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
         self
     }
 
-    pub fn left_avatar(mut self, left_avatar: impl Into<ImageSource>) -> Self {
-        self.left_slot = Some(Avatar::new(left_avatar).into_any_element());
+    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
+        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
         self
     }
 }
@@ -125,49 +126,105 @@ impl RenderOnce for ListItem {
     type Rendered = Stateful<Div>;
 
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        div()
-            .id(self.id)
+        h_stack()
+            .id("item_container")
+            .w_full()
             .relative()
-            // TODO: Add focus state
-            // .when(self.state == InteractionState::Focused, |this| {
-            //     this.border()
-            //         .border_color(cx.theme().colors().border_focused)
-            // })
-            .when(self.inset, |this| this.rounded_md())
-            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
-            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
-            .when(self.selected, |this| {
-                this.bg(cx.theme().colors().ghost_element_selected)
+            // When an item is inset draw the indent spacing outside of the item
+            .when(self.inset, |this| {
+                this.ml(self.indent_level as f32 * self.indent_step_size)
+                    .px_1()
             })
-            .when_some(self.on_click, |this, on_click| {
-                this.cursor_pointer().on_click(move |event, cx| {
-                    // HACK: GPUI currently fires `on_click` with any mouse button,
-                    // but we only care about the left button.
-                    if event.down.button == MouseButton::Left {
-                        (on_click)(event, cx)
-                    }
-                })
+            .when(!self.inset, |this| {
+                this
+                    // TODO: Add focus state
+                    // .when(self.state == InteractionState::Focused, |this| {
+                    //     this.border()
+                    //         .border_color(cx.theme().colors().border_focused)
+                    // })
+                    .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+                    .active(|style| style.bg(cx.theme().colors().ghost_element_active))
+                    .when(self.selected, |this| {
+                        this.bg(cx.theme().colors().ghost_element_selected)
+                    })
             })
-            .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
-                this.on_mouse_down(MouseButton::Right, move |event, cx| {
-                    (on_mouse_down)(event, cx)
-                })
-            })
-            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
             .child(
-                div()
-                    .when(self.inset, |this| this.px_2())
-                    .ml(self.indent_level as f32 * self.indent_step_size)
-                    .flex()
-                    .gap_1()
-                    .items_center()
+                h_stack()
+                    .id(self.id)
+                    .w_full()
                     .relative()
+                    .gap_1()
+                    .px_2()
+                    .group("list_item")
+                    .when(self.inset, |this| {
+                        this
+                            // TODO: Add focus state
+                            // .when(self.state == InteractionState::Focused, |this| {
+                            //     this.border()
+                            //         .border_color(cx.theme().colors().border_focused)
+                            // })
+                            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+                            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
+                            .when(self.selected, |this| {
+                                this.bg(cx.theme().colors().ghost_element_selected)
+                            })
+                    })
+                    .when_some(self.on_click, |this, on_click| {
+                        this.cursor_pointer().on_click(move |event, cx| {
+                            // HACK: GPUI currently fires `on_click` with any mouse button,
+                            // but we only care about the left button.
+                            if event.down.button == MouseButton::Left {
+                                (on_click)(event, cx)
+                            }
+                        })
+                    })
+                    .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
+                        this.on_mouse_down(MouseButton::Right, move |event, cx| {
+                            (on_mouse_down)(event, cx)
+                        })
+                    })
+                    .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
+                    .map(|this| {
+                        if self.inset {
+                            this.rounded_md()
+                        } else {
+                            // When an item is not inset draw the indent spacing inside of the item
+                            this.ml(self.indent_level as f32 * self.indent_step_size)
+                        }
+                    })
                     .children(
                         self.toggle
                             .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)),
                     )
-                    .children(self.left_slot)
-                    .children(self.children),
+                    .child(
+                        h_stack()
+                            .flex_1()
+                            .gap_1()
+                            .children(self.start_slot)
+                            .children(self.children),
+                    )
+                    .when_some(self.end_slot, |this, end_slot| {
+                        this.justify_between().child(
+                            h_stack()
+                                .when(self.end_hover_slot.is_some(), |this| {
+                                    this.visible()
+                                        .group_hover("list_item", |this| this.invisible())
+                                })
+                                .child(end_slot),
+                        )
+                    })
+                    .when_some(self.end_hover_slot, |this, end_hover_slot| {
+                        this.child(
+                            h_stack()
+                                .h_full()
+                                .absolute()
+                                .right_2()
+                                .top_0()
+                                .invisible()
+                                .group_hover("list_item", |this| this.visible())
+                                .child(end_hover_slot),
+                        )
+                    }),
             )
     }
 }

crates/ui2/src/components/list/list_separator.rs 🔗

@@ -9,6 +9,10 @@ impl RenderOnce for ListSeparator {
     type Rendered = Div;
 
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        div().h_px().w_full().bg(cx.theme().colors().border_variant)
+        div()
+            .h_px()
+            .w_full()
+            .my_1()
+            .bg(cx.theme().colors().border_variant)
     }
 }

crates/ui2/src/components/list/list_sub_header.rs 🔗

@@ -6,7 +6,7 @@ use crate::{h_stack, Icon, IconElement, IconSize, Label};
 #[derive(IntoElement)]
 pub struct ListSubHeader {
     label: SharedString,
-    left_icon: Option<Icon>,
+    start_slot: Option<Icon>,
     inset: bool,
 }
 
@@ -14,13 +14,13 @@ impl ListSubHeader {
     pub fn new(label: impl Into<SharedString>) -> Self {
         Self {
             label: label.into(),
-            left_icon: None,
+            start_slot: None,
             inset: false,
         }
     }
 
     pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
-        self.left_icon = left_icon;
+        self.start_slot = left_icon;
         self
     }
 }
@@ -44,7 +44,7 @@ impl RenderOnce for ListSubHeader {
                         .flex()
                         .gap_1()
                         .items_center()
-                        .children(self.left_icon.map(|i| {
+                        .children(self.start_slot.map(|i| {
                             IconElement::new(i)
                                 .color(Color::Muted)
                                 .size(IconSize::Small)

crates/ui2/src/components/stories/list_header.rs 🔗

@@ -15,19 +15,19 @@ impl Render for ListHeaderStory {
             .child(Story::label("Default"))
             .child(ListHeader::new("Section 1"))
             .child(Story::label("With left icon"))
-            .child(ListHeader::new("Section 2").left_icon(Icon::Bell))
+            .child(ListHeader::new("Section 2").start_slot(IconElement::new(Icon::Bell)))
             .child(Story::label("With left icon and meta"))
             .child(
                 ListHeader::new("Section 3")
-                    .left_icon(Icon::BellOff)
-                    .meta(IconButton::new("action_1", Icon::Bolt)),
+                    .start_slot(IconElement::new(Icon::BellOff))
+                    .end_slot(IconButton::new("action_1", Icon::Bolt)),
             )
             .child(Story::label("With multiple meta"))
             .child(
                 ListHeader::new("Section 4")
-                    .meta(IconButton::new("action_1", Icon::Bolt))
-                    .meta(IconButton::new("action_2", Icon::ExclamationTriangle))
-                    .meta(IconButton::new("action_3", Icon::Plus)),
+                    .end_slot(IconButton::new("action_1", Icon::Bolt))
+                    .end_slot(IconButton::new("action_2", Icon::ExclamationTriangle))
+                    .end_slot(IconButton::new("action_3", Icon::Plus)),
             )
     }
 }

crates/ui2/src/components/stories/list_item.rs 🔗

@@ -1,7 +1,7 @@
 use gpui::{Div, Render};
 use story::Story;
 
-use crate::prelude::*;
+use crate::{prelude::*, Avatar};
 use crate::{Icon, ListItem};
 
 pub struct ListItemStory;
@@ -9,24 +9,80 @@ pub struct ListItemStory;
 impl Render for ListItemStory {
     type Element = Div;
 
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
         Story::container()
+            .bg(cx.theme().colors().background)
             .child(Story::title_for::<ListItem>())
             .child(Story::label("Default"))
             .child(ListItem::new("hello_world").child("Hello, world!"))
-            .child(Story::label("With left icon"))
+            .child(Story::label("Inset"))
             .child(
-                ListItem::new("with_left_icon")
+                ListItem::new("hello_world")
+                    .inset(true)
+                    .start_slot(
+                        IconElement::new(Icon::Bell)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .child("Hello, world!")
-                    .left_icon(Icon::Bell),
+                    .end_slot(
+                        IconElement::new(Icon::Bell)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
             )
-            .child(Story::label("With left avatar"))
+            .child(Story::label("With start slot icon"))
+            .child(
+                ListItem::new("with start slot_icon")
+                    .child("Hello, world!")
+                    .start_slot(
+                        IconElement::new(Icon::Bell)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
+            )
+            .child(Story::label("With start slot avatar"))
+            .child(
+                ListItem::new("with_start slot avatar")
+                    .child("Hello, world!")
+                    .start_slot(Avatar::new(SharedString::from(
+                        "https://avatars.githubusercontent.com/u/1714999?v=4",
+                    ))),
+            )
+            .child(Story::label("With end slot"))
+            .child(
+                ListItem::new("with_left_avatar")
+                    .child("Hello, world!")
+                    .end_slot(Avatar::new(SharedString::from(
+                        "https://avatars.githubusercontent.com/u/1714999?v=4",
+                    ))),
+            )
+            .child(Story::label("With end hover slot"))
             .child(
                 ListItem::new("with_left_avatar")
                     .child("Hello, world!")
-                    .left_avatar(SharedString::from(
+                    .end_slot(
+                        h_stack()
+                            .gap_2()
+                            .child(Avatar::new(SharedString::from(
+                                "https://avatars.githubusercontent.com/u/1789?v=4",
+                            )))
+                            .child(Avatar::new(SharedString::from(
+                                "https://avatars.githubusercontent.com/u/1789?v=4",
+                            )))
+                            .child(Avatar::new(SharedString::from(
+                                "https://avatars.githubusercontent.com/u/1789?v=4",
+                            )))
+                            .child(Avatar::new(SharedString::from(
+                                "https://avatars.githubusercontent.com/u/1789?v=4",
+                            )))
+                            .child(Avatar::new(SharedString::from(
+                                "https://avatars.githubusercontent.com/u/1789?v=4",
+                            ))),
+                    )
+                    .end_hover_slot(Avatar::new(SharedString::from(
                         "https://avatars.githubusercontent.com/u/1714999?v=4",
-                    )),
+                    ))),
             )
             .child(Story::label("With `on_click`"))
             .child(

crates/ui2/src/styled_ext.rs 🔗

@@ -118,16 +118,26 @@ pub trait StyledExt: Styled + Sized {
         elevated(self, cx, ElevationIndex::ModalSurface)
     }
 
+    /// The theme's primary border color.
+    fn border_primary(self, cx: &mut WindowContext) -> Self {
+        self.border_color(cx.theme().colors().border)
+    }
+
+    /// The theme's secondary or muted border color.
+    fn border_muted(self, cx: &mut WindowContext) -> Self {
+        self.border_color(cx.theme().colors().border_variant)
+    }
+
     fn debug_bg_red(self) -> Self {
-        self.bg(gpui::red())
+        self.bg(hsla(0. / 360., 1., 0.5, 1.))
     }
 
     fn debug_bg_green(self) -> Self {
-        self.bg(gpui::green())
+        self.bg(hsla(120. / 360., 1., 0.5, 1.))
     }
 
     fn debug_bg_blue(self) -> Self {
-        self.bg(gpui::blue())
+        self.bg(hsla(240. / 360., 1., 0.5, 1.))
     }
 
     fn debug_bg_yellow(self) -> Self {