Reorganize list components (#3440)

Marshall Bowers created

This PR reorganizes the list components so that each of the sub
components lives in its own file.

### Motivation

I've seen a number of folks have trouble finding the `ListItem`
definition while pairing, so having it in its own file seems more
self-explanatory.

Release Notes:

- N/A

Change summary

crates/storybook2/src/story_selector.rs           |   2 
crates/ui2/src/components/context_menu.rs         |   2 
crates/ui2/src/components/list.rs                 | 396 +---------------
crates/ui2/src/components/list/list_header.rs     | 123 +++++
crates/ui2/src/components/list/list_item.rs       | 167 +++++++
crates/ui2/src/components/list/list_separator.rs  |  14 
crates/ui2/src/components/list/list_sub_header.rs |  56 ++
crates/ui2/src/components/stories.rs              |   3 
crates/ui2/src/components/stories/list.rs         |  38 +
9 files changed, 429 insertions(+), 372 deletions(-)

Detailed changes

crates/storybook2/src/story_selector.rs 🔗

@@ -21,6 +21,7 @@ pub enum ComponentStory {
     IconButton,
     Keybinding,
     Label,
+    List,
     ListItem,
     Scroll,
     Text,
@@ -40,6 +41,7 @@ impl ComponentStory {
             Self::IconButton => cx.build_view(|_| ui::IconButtonStory).into(),
             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::ListItem => cx.build_view(|_| ui::ListItemStory).into(),
             Self::Scroll => ScrollStory::view(cx).into(),
             Self::Text => TextStory::view(cx).into(),

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

@@ -171,7 +171,7 @@ impl Render for ContextMenu {
                 .child(
                     List::new().children(self.items.iter().enumerate().map(
                         |(ix, item)| match item {
-                            ContextMenuItem::Separator => ListSeparator::new().into_any_element(),
+                            ContextMenuItem::Separator => ListSeparator.into_any_element(),
                             ContextMenuItem::Header(header) => {
                                 ListSubHeader::new(header.clone()).into_any_element()
                             }

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

@@ -1,249 +1,46 @@
-use std::rc::Rc;
+mod list_header;
+mod list_item;
+mod list_separator;
+mod list_sub_header;
 
-use gpui::{
-    div, px, AnyElement, ClickEvent, Div, ImageSource, IntoElement, MouseButton, MouseDownEvent,
-    Pixels, Stateful, StatefulInteractiveElement,
-};
+use gpui::{AnyElement, Div};
 use smallvec::SmallVec;
 
-use crate::{
-    disclosure_control, h_stack, v_stack, Avatar, Icon, IconButton, IconElement, IconSize, Label,
-    Toggle,
-};
-use crate::{prelude::*, GraphicSlot};
+use crate::prelude::*;
+use crate::{v_stack, Label, Toggle};
 
-pub enum ListHeaderMeta {
-    Tools(Vec<IconButton>),
-    // TODO: This should be a button
-    Button(Label),
-    Text(Label),
-}
-
-#[derive(IntoElement)]
-pub struct ListHeader {
-    label: SharedString,
-    left_icon: Option<Icon>,
-    meta: Option<ListHeaderMeta>,
-    toggle: Toggle,
-    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
-    inset: bool,
-    selected: bool,
-}
-
-impl ListHeader {
-    pub fn new(label: impl Into<SharedString>) -> Self {
-        Self {
-            label: label.into(),
-            left_icon: None,
-            meta: None,
-            inset: false,
-            toggle: Toggle::NotToggleable,
-            on_toggle: None,
-            selected: false,
-        }
-    }
-
-    pub fn toggle(mut self, toggle: Toggle) -> Self {
-        self.toggle = toggle;
-        self
-    }
-
-    pub fn on_toggle(
-        mut self,
-        on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
-    ) -> Self {
-        self.on_toggle = Some(Rc::new(on_toggle));
-        self
-    }
-
-    pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
-        self.left_icon = left_icon;
-        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;
-        self
-    }
-
-    pub fn selected(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-}
-
-impl RenderOnce for ListHeader {
-    type Rendered = Div;
-
-    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        let disclosure_control = disclosure_control(self.toggle, self.on_toggle);
-
-        let meta = match self.meta {
-            Some(ListHeaderMeta::Tools(icons)) => div().child(
-                h_stack()
-                    .gap_2()
-                    .items_center()
-                    .children(icons.into_iter().map(|i| i.color(Color::Muted))),
-            ),
-            Some(ListHeaderMeta::Button(label)) => div().child(label),
-            Some(ListHeaderMeta::Text(label)) => div().child(label),
-            None => div(),
-        };
-
-        h_stack()
-            .w_full()
-            .bg(cx.theme().colors().surface_background)
-            .relative()
-            .child(
-                div()
-                    .h_5()
-                    .when(self.inset, |this| this.px_2())
-                    .when(self.selected, |this| {
-                        this.bg(cx.theme().colors().ghost_element_selected)
-                    })
-                    .flex()
-                    .flex_1()
-                    .items_center()
-                    .justify_between()
-                    .w_full()
-                    .gap_1()
-                    .child(
-                        h_stack()
-                            .gap_1()
-                            .child(
-                                div()
-                                    .flex()
-                                    .gap_1()
-                                    .items_center()
-                                    .children(self.left_icon.map(|i| {
-                                        IconElement::new(i)
-                                            .color(Color::Muted)
-                                            .size(IconSize::Small)
-                                    }))
-                                    .child(Label::new(self.label.clone()).color(Color::Muted)),
-                            )
-                            .child(disclosure_control),
-                    )
-                    .child(meta),
-            )
-    }
-}
-
-#[derive(IntoElement, Clone)]
-pub struct ListSubHeader {
-    label: SharedString,
-    left_icon: Option<Icon>,
-    inset: bool,
-}
-
-impl ListSubHeader {
-    pub fn new(label: impl Into<SharedString>) -> Self {
-        Self {
-            label: label.into(),
-            left_icon: None,
-            inset: false,
-        }
-    }
-
-    pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
-        self.left_icon = left_icon;
-        self
-    }
-}
-
-impl RenderOnce for ListSubHeader {
-    type Rendered = Div;
-
-    fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
-        h_stack().flex_1().w_full().relative().py_1().child(
-            div()
-                .h_6()
-                .when(self.inset, |this| this.px_2())
-                .flex()
-                .flex_1()
-                .w_full()
-                .gap_1()
-                .items_center()
-                .justify_between()
-                .child(
-                    div()
-                        .flex()
-                        .gap_1()
-                        .items_center()
-                        .children(self.left_icon.map(|i| {
-                            IconElement::new(i)
-                                .color(Color::Muted)
-                                .size(IconSize::Small)
-                        }))
-                        .child(Label::new(self.label.clone()).color(Color::Muted)),
-                ),
-        )
-    }
-}
+pub use list_header::*;
+pub use list_item::*;
+pub use list_separator::*;
+pub use list_sub_header::*;
 
 #[derive(IntoElement)]
-pub struct ListItem {
-    id: ElementId,
-    selected: bool,
-    // TODO: Reintroduce this
-    // disclosure_control_style: DisclosureControlVisibility,
-    indent_level: usize,
-    indent_step_size: Pixels,
-    left_slot: Option<GraphicSlot>,
+pub struct List {
+    /// Message to display when the list is empty
+    /// Defaults to "No items"
+    empty_message: SharedString,
+    header: Option<ListHeader>,
     toggle: Toggle,
-    inset: bool,
-    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
-    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
-    on_secondary_mouse_down: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
 }
 
-impl ListItem {
-    pub fn new(id: impl Into<ElementId>) -> Self {
+impl List {
+    pub fn new() -> Self {
         Self {
-            id: id.into(),
-            selected: false,
-            indent_level: 0,
-            indent_step_size: px(12.),
-            left_slot: None,
+            empty_message: "No items".into(),
+            header: None,
             toggle: Toggle::NotToggleable,
-            inset: false,
-            on_click: None,
-            on_secondary_mouse_down: None,
-            on_toggle: None,
             children: SmallVec::new(),
         }
     }
 
-    pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
-        self.on_click = Some(Rc::new(handler));
-        self
-    }
-
-    pub fn on_secondary_mouse_down(
-        mut self,
-        handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
-    ) -> Self {
-        self.on_secondary_mouse_down = Some(Rc::new(handler));
-        self
-    }
-
-    pub fn inset(mut self, inset: bool) -> Self {
-        self.inset = inset;
-        self
-    }
-
-    pub fn indent_level(mut self, indent_level: usize) -> Self {
-        self.indent_level = indent_level;
+    pub fn empty_message(mut self, empty_message: impl Into<SharedString>) -> Self {
+        self.empty_message = empty_message.into();
         self
     }
 
-    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
-        self.indent_step_size = indent_step_size;
+    pub fn header(mut self, header: ListHeader) -> Self {
+        self.header = Some(header);
         self
     }
 
@@ -251,125 +48,14 @@ impl ListItem {
         self.toggle = toggle;
         self
     }
-
-    pub fn on_toggle(
-        mut self,
-        on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
-    ) -> Self {
-        self.on_toggle = Some(Rc::new(on_toggle));
-        self
-    }
-
-    pub fn selected(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-
-    pub fn left_content(mut self, left_content: GraphicSlot) -> Self {
-        self.left_slot = Some(left_content);
-        self
-    }
-
-    pub fn left_icon(mut self, left_icon: Icon) -> Self {
-        self.left_slot = Some(GraphicSlot::Icon(left_icon));
-        self
-    }
-
-    pub fn left_avatar(mut self, left_avatar: impl Into<ImageSource>) -> Self {
-        self.left_slot = Some(GraphicSlot::Avatar(left_avatar.into()));
-        self
-    }
-}
-
-impl RenderOnce for ListItem {
-    type Rendered = Stateful<Div>;
-
-    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        div()
-            .id(self.id)
-            .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_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)
-                })
-            })
-            .child(
-                div()
-                    .when(self.inset, |this| this.px_2())
-                    .ml(self.indent_level as f32 * self.indent_step_size)
-                    .flex()
-                    .gap_1()
-                    .items_center()
-                    .relative()
-                    .child(disclosure_control(self.toggle, self.on_toggle))
-                    .map(|this| match self.left_slot {
-                        Some(GraphicSlot::Icon(i)) => this.child(
-                            IconElement::new(i)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        ),
-                        Some(GraphicSlot::Avatar(src)) => this.child(Avatar::source(src)),
-                        Some(GraphicSlot::PublicActor(src)) => this.child(Avatar::uri(src)),
-                        None => this,
-                    })
-                    .children(self.children),
-            )
-    }
 }
 
-impl ParentElement for ListItem {
+impl ParentElement for List {
     fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
         &mut self.children
     }
 }
 
-#[derive(IntoElement, Clone)]
-pub struct ListSeparator;
-
-impl ListSeparator {
-    pub fn new() -> Self {
-        Self
-    }
-}
-
-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)
-    }
-}
-
-#[derive(IntoElement)]
-pub struct List {
-    /// Message to display when the list is empty
-    /// Defaults to "No items"
-    empty_message: SharedString,
-    header: Option<ListHeader>,
-    toggle: Toggle,
-    children: SmallVec<[AnyElement; 2]>,
-}
-
 impl RenderOnce for List {
     type Rendered = Div;
 
@@ -385,35 +71,3 @@ impl RenderOnce for List {
             })
     }
 }
-
-impl List {
-    pub fn new() -> Self {
-        Self {
-            empty_message: "No items".into(),
-            header: None,
-            toggle: Toggle::NotToggleable,
-            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: Toggle) -> Self {
-        self.toggle = toggle;
-        self
-    }
-}
-
-impl ParentElement for List {
-    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
-        &mut self.children
-    }
-}

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

@@ -0,0 +1,123 @@
+use std::rc::Rc;
+
+use gpui::{ClickEvent, Div};
+
+use crate::prelude::*;
+use crate::{disclosure_control, h_stack, Icon, IconButton, IconElement, IconSize, Label, Toggle};
+
+pub enum ListHeaderMeta {
+    Tools(Vec<IconButton>),
+    // TODO: This should be a button
+    Button(Label),
+    Text(Label),
+}
+
+#[derive(IntoElement)]
+pub struct ListHeader {
+    label: SharedString,
+    left_icon: Option<Icon>,
+    meta: Option<ListHeaderMeta>,
+    toggle: Toggle,
+    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    inset: bool,
+    selected: bool,
+}
+
+impl ListHeader {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            left_icon: None,
+            meta: None,
+            inset: false,
+            toggle: Toggle::NotToggleable,
+            on_toggle: None,
+            selected: false,
+        }
+    }
+
+    pub fn toggle(mut self, toggle: Toggle) -> Self {
+        self.toggle = toggle;
+        self
+    }
+
+    pub fn on_toggle(
+        mut self,
+        on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_toggle = Some(Rc::new(on_toggle));
+        self
+    }
+
+    pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
+        self.left_icon = left_icon;
+        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;
+        self
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl RenderOnce for ListHeader {
+    type Rendered = Div;
+
+    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
+        let disclosure_control = disclosure_control(self.toggle, self.on_toggle);
+
+        let meta = match self.meta {
+            Some(ListHeaderMeta::Tools(icons)) => div().child(
+                h_stack()
+                    .gap_2()
+                    .items_center()
+                    .children(icons.into_iter().map(|i| i.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()
+                .when(self.inset, |this| this.px_2())
+                .when(self.selected, |this| {
+                    this.bg(cx.theme().colors().ghost_element_selected)
+                })
+                .flex()
+                .flex_1()
+                .items_center()
+                .justify_between()
+                .w_full()
+                .gap_1()
+                .child(
+                    h_stack()
+                        .gap_1()
+                        .child(
+                            div()
+                                .flex()
+                                .gap_1()
+                                .items_center()
+                                .children(self.left_icon.map(|i| {
+                                    IconElement::new(i)
+                                        .color(Color::Muted)
+                                        .size(IconSize::Small)
+                                }))
+                                .child(Label::new(self.label.clone()).color(Color::Muted)),
+                        )
+                        .child(disclosure_control),
+                )
+                .child(meta),
+        )
+    }
+}

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

@@ -0,0 +1,167 @@
+use std::rc::Rc;
+
+use gpui::{
+    px, AnyElement, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels, Stateful,
+};
+use smallvec::SmallVec;
+
+use crate::prelude::*;
+use crate::{disclosure_control, Avatar, GraphicSlot, Icon, IconElement, IconSize, Toggle};
+
+#[derive(IntoElement)]
+pub struct ListItem {
+    id: ElementId,
+    selected: bool,
+    // TODO: Reintroduce this
+    // disclosure_control_style: DisclosureControlVisibility,
+    indent_level: usize,
+    indent_step_size: Pixels,
+    left_slot: Option<GraphicSlot>,
+    toggle: Toggle,
+    inset: bool,
+    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_secondary_mouse_down: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl ListItem {
+    pub fn new(id: impl Into<ElementId>) -> Self {
+        Self {
+            id: id.into(),
+            selected: false,
+            indent_level: 0,
+            indent_step_size: px(12.),
+            left_slot: None,
+            toggle: Toggle::NotToggleable,
+            inset: false,
+            on_click: None,
+            on_secondary_mouse_down: None,
+            on_toggle: None,
+            children: SmallVec::new(),
+        }
+    }
+
+    pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
+        self.on_click = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn on_secondary_mouse_down(
+        mut self,
+        handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_secondary_mouse_down = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn inset(mut self, inset: bool) -> Self {
+        self.inset = inset;
+        self
+    }
+
+    pub fn indent_level(mut self, indent_level: usize) -> Self {
+        self.indent_level = indent_level;
+        self
+    }
+
+    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
+        self.indent_step_size = indent_step_size;
+        self
+    }
+
+    pub fn toggle(mut self, toggle: Toggle) -> Self {
+        self.toggle = toggle;
+        self
+    }
+
+    pub fn on_toggle(
+        mut self,
+        on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_toggle = Some(Rc::new(on_toggle));
+        self
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+
+    pub fn left_content(mut self, left_content: GraphicSlot) -> Self {
+        self.left_slot = Some(left_content);
+        self
+    }
+
+    pub fn left_icon(mut self, left_icon: Icon) -> Self {
+        self.left_slot = Some(GraphicSlot::Icon(left_icon));
+        self
+    }
+
+    pub fn left_avatar(mut self, left_avatar: impl Into<ImageSource>) -> Self {
+        self.left_slot = Some(GraphicSlot::Avatar(left_avatar.into()));
+        self
+    }
+}
+
+impl ParentElement for ListItem {
+    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
+        &mut self.children
+    }
+}
+
+impl RenderOnce for ListItem {
+    type Rendered = Stateful<Div>;
+
+    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
+        div()
+            .id(self.id)
+            .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_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)
+                })
+            })
+            .child(
+                div()
+                    .when(self.inset, |this| this.px_2())
+                    .ml(self.indent_level as f32 * self.indent_step_size)
+                    .flex()
+                    .gap_1()
+                    .items_center()
+                    .relative()
+                    .child(disclosure_control(self.toggle, self.on_toggle))
+                    .map(|this| match self.left_slot {
+                        Some(GraphicSlot::Icon(i)) => this.child(
+                            IconElement::new(i)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
+                        Some(GraphicSlot::Avatar(src)) => this.child(Avatar::source(src)),
+                        Some(GraphicSlot::PublicActor(src)) => this.child(Avatar::uri(src)),
+                        None => this,
+                    })
+                    .children(self.children),
+            )
+    }
+}

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

@@ -0,0 +1,14 @@
+use gpui::Div;
+
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct ListSeparator;
+
+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)
+    }
+}

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

@@ -0,0 +1,56 @@
+use gpui::Div;
+
+use crate::prelude::*;
+use crate::{h_stack, Icon, IconElement, IconSize, Label};
+
+#[derive(IntoElement)]
+pub struct ListSubHeader {
+    label: SharedString,
+    left_icon: Option<Icon>,
+    inset: bool,
+}
+
+impl ListSubHeader {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            left_icon: None,
+            inset: false,
+        }
+    }
+
+    pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
+        self.left_icon = left_icon;
+        self
+    }
+}
+
+impl RenderOnce for ListSubHeader {
+    type Rendered = Div;
+
+    fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
+        h_stack().flex_1().w_full().relative().py_1().child(
+            div()
+                .h_6()
+                .when(self.inset, |this| this.px_2())
+                .flex()
+                .flex_1()
+                .w_full()
+                .gap_1()
+                .items_center()
+                .justify_between()
+                .child(
+                    div()
+                        .flex()
+                        .gap_1()
+                        .items_center()
+                        .children(self.left_icon.map(|i| {
+                            IconElement::new(i)
+                                .color(Color::Muted)
+                                .size(IconSize::Small)
+                        }))
+                        .child(Label::new(self.label.clone()).color(Color::Muted)),
+                ),
+        )
+    }
+}

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

@@ -6,7 +6,9 @@ mod icon;
 mod icon_button;
 mod keybinding;
 mod label;
+mod list;
 mod list_item;
+
 pub use avatar::*;
 pub use button::*;
 pub use checkbox::*;
@@ -15,4 +17,5 @@ pub use icon::*;
 pub use icon_button::*;
 pub use keybinding::*;
 pub use label::*;
+pub use list::*;
 pub use list_item::*;

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

@@ -0,0 +1,38 @@
+use gpui::{Div, Render};
+use story::Story;
+
+use crate::{prelude::*, ListHeader, ListSeparator, ListSubHeader};
+use crate::{List, ListItem};
+
+pub struct ListStory;
+
+impl Render for ListStory {
+    type Element = Div;
+
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+        Story::container()
+            .child(Story::title_for::<List>())
+            .child(Story::label("Default"))
+            .child(
+                List::new()
+                    .child(ListItem::new("apple").child("Apple"))
+                    .child(ListItem::new("banana").child("Banana"))
+                    .child(ListItem::new("cherry").child("Cherry")),
+            )
+            .child(Story::label("With sections"))
+            .child(
+                List::new()
+                    .child(ListHeader::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"))
+                    .child(ListSubHeader::new("Leafy Vegetables"))
+                    .child(ListItem::new("kale").child("Kale")),
+            )
+    }
+}