Rework `ListHeader` to be more open (#3467)

Marshall Bowers created

This PR reworks the `ListHeader` component to be more open.

The `meta` method can now be used to append meta items of any element to
the `ListHeader`, and they will be rendered with the appropriate spacing
between them.

Release Notes:

- N/A

Change summary

crates/collab_ui2/src/collab_panel.rs            |  2 
crates/storybook2/src/story_selector.rs          |  2 
crates/ui2/src/components/button.rs              |  0 
crates/ui2/src/components/list.rs                | 66 -----------------
crates/ui2/src/components/list/list.rs           | 60 ++++++++++++++++
crates/ui2/src/components/list/list_header.rs    | 42 ++--------
crates/ui2/src/components/stories.rs             |  2 
crates/ui2/src/components/stories/list.rs        |  4 
crates/ui2/src/components/stories/list_header.rs | 33 +++++++++
9 files changed, 112 insertions(+), 99 deletions(-)

Detailed changes

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -2511,7 +2511,7 @@ impl CollabPanel {
                 } else {
                     el.child(
                         ListHeader::new(text)
-                            .when_some(button, |el, button| el.right_button(button))
+                            .when_some(button, |el, button| el.meta(button))
                             .selected(is_selected),
                     )
                 }

crates/storybook2/src/story_selector.rs 🔗

@@ -23,6 +23,7 @@ pub enum ComponentStory {
     Keybinding,
     Label,
     List,
+    ListHeader,
     ListItem,
     Scroll,
     Text,
@@ -44,6 +45,7 @@ impl ComponentStory {
             Self::Keybinding => cx.build_view(|_| ui::KeybindingStory).into(),
             Self::Label => cx.build_view(|_| ui::LabelStory).into(),
             Self::List => cx.build_view(|_| ui::ListStory).into(),
+            Self::ListHeader => cx.build_view(|_| ui::ListHeaderStory).into(),
             Self::ListItem => cx.build_view(|_| ui::ListItemStory).into(),
             Self::Scroll => ScrollStory::view(cx).into(),
             Self::Text => TextStory::view(cx).into(),

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

@@ -1,73 +1,11 @@
+mod list;
 mod list_header;
 mod list_item;
 mod list_separator;
 mod list_sub_header;
 
-use gpui::{AnyElement, Div};
-use smallvec::SmallVec;
-
-use crate::prelude::*;
-use crate::{v_stack, Label};
-
+pub use list::*;
 pub use list_header::*;
 pub use list_item::*;
 pub use list_separator::*;
 pub use list_sub_header::*;
-
-#[derive(IntoElement)]
-pub struct List {
-    /// Message to display when the list is empty
-    /// Defaults to "No items"
-    empty_message: SharedString,
-    header: Option<ListHeader>,
-    toggle: Option<bool>,
-    children: SmallVec<[AnyElement; 2]>,
-}
-
-impl List {
-    pub fn new() -> Self {
-        Self {
-            empty_message: "No items".into(),
-            header: None,
-            toggle: None,
-            children: SmallVec::new(),
-        }
-    }
-
-    pub fn empty_message(mut self, empty_message: impl Into<SharedString>) -> Self {
-        self.empty_message = empty_message.into();
-        self
-    }
-
-    pub fn header(mut self, header: ListHeader) -> Self {
-        self.header = Some(header);
-        self
-    }
-
-    pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
-        self.toggle = toggle.into();
-        self
-    }
-}
-
-impl ParentElement for List {
-    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
-        &mut self.children
-    }
-}
-
-impl RenderOnce for List {
-    type Rendered = Div;
-
-    fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
-        v_stack()
-            .w_full()
-            .py_1()
-            .children(self.header.map(|header| header))
-            .map(|this| match (self.children.is_empty(), self.toggle) {
-                (false, _) => this.children(self.children),
-                (true, Some(false)) => this,
-                (true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)),
-            })
-    }
-}

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

@@ -0,0 +1,60 @@
+use gpui::{AnyElement, Div};
+use smallvec::SmallVec;
+
+use crate::{prelude::*, v_stack, Label, ListHeader};
+
+#[derive(IntoElement)]
+pub struct List {
+    /// Message to display when the list is empty
+    /// Defaults to "No items"
+    empty_message: SharedString,
+    header: Option<ListHeader>,
+    toggle: Option<bool>,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl List {
+    pub fn new() -> Self {
+        Self {
+            empty_message: "No items".into(),
+            header: None,
+            toggle: None,
+            children: SmallVec::new(),
+        }
+    }
+
+    pub fn empty_message(mut self, empty_message: impl Into<SharedString>) -> Self {
+        self.empty_message = empty_message.into();
+        self
+    }
+
+    pub fn header(mut self, header: impl Into<Option<ListHeader>>) -> Self {
+        self.header = header.into();
+        self
+    }
+
+    pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
+        self.toggle = toggle.into();
+        self
+    }
+}
+
+impl ParentElement for List {
+    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
+        &mut self.children
+    }
+}
+
+impl RenderOnce for List {
+    type Rendered = Div;
+
+    fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
+        v_stack().w_full().py_1().children(self.header).map(|this| {
+            match (self.children.is_empty(), self.toggle) {
+                (false, _) => this.children(self.children),
+                (true, Some(false)) => this,
+                (true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)),
+            }
+        })
+    }
+}

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

@@ -1,22 +1,16 @@
 use std::rc::Rc;
 
-use gpui::{ClickEvent, Div};
+use gpui::{AnyElement, ClickEvent, Div};
+use smallvec::SmallVec;
 
 use crate::prelude::*;
-use crate::{h_stack, Disclosure, Icon, IconButton, IconElement, IconSize, Label};
-
-pub enum ListHeaderMeta {
-    Tools(Vec<IconButton>),
-    // TODO: This should be a button
-    Button(Label),
-    Text(Label),
-}
+use crate::{h_stack, Disclosure, Icon, IconElement, IconSize, Label};
 
 #[derive(IntoElement)]
 pub struct ListHeader {
     label: SharedString,
     left_icon: Option<Icon>,
-    meta: Option<ListHeaderMeta>,
+    meta: SmallVec<[AnyElement; 2]>,
     toggle: Option<bool>,
     on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
     inset: bool,
@@ -28,7 +22,7 @@ impl ListHeader {
         Self {
             label: label.into(),
             left_icon: None,
-            meta: None,
+            meta: SmallVec::new(),
             inset: false,
             toggle: None,
             on_toggle: None,
@@ -49,17 +43,13 @@ impl ListHeader {
         self
     }
 
-    pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
-        self.left_icon = left_icon;
+    pub fn left_icon(mut self, left_icon: impl Into<Option<Icon>>) -> Self {
+        self.left_icon = left_icon.into();
         self
     }
 
-    pub fn right_button(self, button: IconButton) -> Self {
-        self.meta(Some(ListHeaderMeta::Tools(vec![button])))
-    }
-
-    pub fn meta(mut self, meta: Option<ListHeaderMeta>) -> Self {
-        self.meta = meta;
+    pub fn meta(mut self, meta: impl IntoElement) -> Self {
+        self.meta.push(meta.into_any_element());
         self
     }
 }
@@ -75,18 +65,6 @@ impl RenderOnce for ListHeader {
     type Rendered = Div;
 
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        let meta = match self.meta {
-            Some(ListHeaderMeta::Tools(icons)) => div().child(
-                h_stack()
-                    .gap_2()
-                    .items_center()
-                    .children(icons.into_iter().map(|i| i.icon_color(Color::Muted))),
-            ),
-            Some(ListHeaderMeta::Button(label)) => div().child(label),
-            Some(ListHeaderMeta::Text(label)) => div().child(label),
-            None => div(),
-        };
-
         h_stack().w_full().relative().child(
             div()
                 .h_5()
@@ -120,7 +98,7 @@ impl RenderOnce for ListHeader {
                                 .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)),
                         ),
                 )
-                .child(meta),
+                .child(h_stack().gap_2().items_center().children(self.meta)),
         )
     }
 }

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

@@ -8,6 +8,7 @@ mod icon_button;
 mod keybinding;
 mod label;
 mod list;
+mod list_header;
 mod list_item;
 
 pub use avatar::*;
@@ -20,4 +21,5 @@ pub use icon_button::*;
 pub use keybinding::*;
 pub use label::*;
 pub use list::*;
+pub use list_header::*;
 pub use list_item::*;

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

@@ -22,12 +22,12 @@ impl Render for ListStory {
             .child(Story::label("With sections"))
             .child(
                 List::new()
-                    .child(ListHeader::new("Fruits"))
+                    .header(ListHeader::new("Produce"))
+                    .child(ListSubHeader::new("Fruits"))
                     .child(ListItem::new("apple").child("Apple"))
                     .child(ListItem::new("banana").child("Banana"))
                     .child(ListItem::new("cherry").child("Cherry"))
                     .child(ListSeparator)
-                    .child(ListHeader::new("Vegetables"))
                     .child(ListSubHeader::new("Root Vegetables"))
                     .child(ListItem::new("carrot").child("Carrot"))
                     .child(ListItem::new("potato").child("Potato"))

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

@@ -0,0 +1,33 @@
+use gpui::{Div, Render};
+use story::Story;
+
+use crate::{prelude::*, IconButton};
+use crate::{Icon, ListHeader};
+
+pub struct ListHeaderStory;
+
+impl Render for ListHeaderStory {
+    type Element = Div;
+
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+        Story::container()
+            .child(Story::title_for::<ListHeader>())
+            .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(Story::label("With left icon and meta"))
+            .child(
+                ListHeader::new("Section 3")
+                    .left_icon(Icon::BellOff)
+                    .meta(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)),
+            )
+    }
+}