Add `ui::ContentGroup` (#20666)

Nate Butler created

TL;DR our version of [HIG's
Box](https://developer.apple.com/design/human-interface-guidelines/boxes)

We can't use the name `Box` (because rust) or `ContentBox` (because
taffy/styles/css).

---

This PR introduces the `ContentGroup` component, a flexible container
inspired by HIG's `Box` component. It's designed to hold and organize
various UI elements with options to toggle borders and background fills.

**Example usage**:

```rust
ContentGroup::new()
    .flex_1()
    .items_center()
    .justify_center()
    .h_48()
    .child(Label::new("Flexible ContentBox"))
```

Here are some configurations:

- Default: Includes both border and fill.
- Borderless: No border for a clean look.
- Unfilled: No background fill for a transparent appearance.

**Preview**:

![CleanShot 2024-11-14 at 07 05
15@2x](https://github.com/user-attachments/assets/c838371e-e24f-46f0-94b4-43c078e8f14e)

---

_This PR was written by a large language model with input from the
author._

Release Notes:

- N/A

Change summary

crates/ui/src/components.rs               |   2 
crates/ui/src/components/content_group.rs | 135 +++++++++++++++++++++++++
crates/ui/src/prelude.rs                  |   2 
crates/ui/src/traits/component_preview.rs |  33 +++++
crates/welcome/src/welcome.rs             |   7 -
crates/workspace/src/theme_preview.rs     |   5 
6 files changed, 173 insertions(+), 11 deletions(-)

Detailed changes

crates/ui/src/components.rs 🔗

@@ -1,6 +1,7 @@
 mod avatar;
 mod button;
 mod checkbox;
+mod content_group;
 mod context_menu;
 mod disclosure;
 mod divider;
@@ -36,6 +37,7 @@ mod stories;
 pub use avatar::*;
 pub use button::*;
 pub use checkbox::*;
+pub use content_group::*;
 pub use context_menu::*;
 pub use disclosure::*;
 pub use divider::*;

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

@@ -0,0 +1,135 @@
+use crate::prelude::*;
+use gpui::{AnyElement, IntoElement, ParentElement, StyleRefinement, Styled};
+use smallvec::SmallVec;
+
+/// Creates a new [ContentGroup].
+pub fn content_group() -> ContentGroup {
+    ContentGroup::new()
+}
+
+/// A [ContentGroup] that vertically stacks its children.
+///
+/// This is a convenience function that simply combines [`ContentGroup`] and [`v_flex`](crate::v_flex).
+pub fn v_group() -> ContentGroup {
+    content_group().v_flex()
+}
+
+/// Creates a new horizontal [ContentGroup].
+///
+/// This is a convenience function that simply combines [`ContentGroup`] and [`h_flex`](crate::h_flex).
+pub fn h_group() -> ContentGroup {
+    content_group().h_flex()
+}
+
+/// A flexible container component that can hold other elements.
+#[derive(IntoElement)]
+pub struct ContentGroup {
+    base: Div,
+    border: bool,
+    fill: bool,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl ContentGroup {
+    /// Creates a new [ContentBox].
+    pub fn new() -> Self {
+        Self {
+            base: div(),
+            border: true,
+            fill: true,
+            children: SmallVec::new(),
+        }
+    }
+
+    /// Removes the border from the [ContentBox].
+    pub fn borderless(mut self) -> Self {
+        self.border = false;
+        self
+    }
+
+    /// Removes the background fill from the [ContentBox].
+    pub fn unfilled(mut self) -> Self {
+        self.fill = false;
+        self
+    }
+}
+
+impl ParentElement for ContentGroup {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl Styled for ContentGroup {
+    fn style(&mut self) -> &mut StyleRefinement {
+        self.base.style()
+    }
+}
+
+impl RenderOnce for ContentGroup {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        // TODO:
+        // Baked in padding will make scrollable views inside of content boxes awkward.
+        //
+        // Do we make the padding optional, or do we push to use a different component?
+
+        self.base
+            .when(self.fill, |this| {
+                this.bg(cx.theme().colors().text.opacity(0.05))
+            })
+            .when(self.border, |this| {
+                this.border_1().border_color(cx.theme().colors().border)
+            })
+            .rounded_md()
+            .p_2()
+            .children(self.children)
+    }
+}
+
+impl ComponentPreview for ContentGroup {
+    fn description() -> impl Into<Option<&'static str>> {
+        "A flexible container component that can hold other elements. It can be customized with or without a border and background fill."
+    }
+
+    fn example_label_side() -> ExampleLabelSide {
+        ExampleLabelSide::Bottom
+    }
+
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
+        vec![example_group(vec![
+            single_example(
+                "Default",
+                ContentGroup::new()
+                    .flex_1()
+                    .items_center()
+                    .justify_center()
+                    .h_48()
+                    .child(Label::new("Default ContentBox")),
+            )
+            .grow(),
+            single_example(
+                "Without Border",
+                ContentGroup::new()
+                    .flex_1()
+                    .items_center()
+                    .justify_center()
+                    .h_48()
+                    .borderless()
+                    .child(Label::new("Borderless ContentBox")),
+            )
+            .grow(),
+            single_example(
+                "Without Fill",
+                ContentGroup::new()
+                    .flex_1()
+                    .items_center()
+                    .justify_center()
+                    .h_48()
+                    .unfilled()
+                    .child(Label::new("Unfilled ContentBox")),
+            )
+            .grow(),
+        ])
+        .grow()]
+    }
+}

crates/ui/src/prelude.rs 🔗

@@ -16,7 +16,7 @@ pub use crate::traits::selectable::*;
 pub use crate::traits::styled_ext::*;
 pub use crate::traits::visible_on_hover::*;
 pub use crate::DynamicSpacing;
-pub use crate::{h_flex, v_flex};
+pub use crate::{h_flex, h_group, v_flex, v_group};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};
 pub use crate::{ButtonCommon, Color};
 pub use crate::{Headline, HeadlineSize};

crates/ui/src/traits/component_preview.rs 🔗

@@ -32,6 +32,10 @@ pub trait ComponentPreview: IntoElement {
 
     fn examples(_cx: &WindowContext) -> Vec<ComponentExampleGroup<Self>>;
 
+    fn custom_example(_cx: &WindowContext) -> impl Into<Option<AnyElement>> {
+        None::<AnyElement>
+    }
+
     fn component_previews(cx: &WindowContext) -> Vec<AnyElement> {
         Self::examples(cx)
             .into_iter()
@@ -47,7 +51,8 @@ pub trait ComponentPreview: IntoElement {
         let description = Self::description().into();
 
         v_flex()
-            .gap_3()
+            .w_full()
+            .gap_6()
             .p_4()
             .border_1()
             .border_color(cx.theme().colors().border)
@@ -73,18 +78,23 @@ pub trait ComponentPreview: IntoElement {
                         )
                     }),
             )
+            .when_some(Self::custom_example(cx).into(), |this, custom_example| {
+                this.child(custom_example)
+            })
             .children(Self::component_previews(cx))
             .into_any_element()
     }
 
     fn render_example_group(group: ComponentExampleGroup<Self>) -> AnyElement {
         v_flex()
-            .gap_2()
+            .gap_6()
+            .when(group.grow, |this| this.w_full().flex_1())
             .when_some(group.title, |this, title| {
                 this.child(Label::new(title).size(LabelSize::Small))
             })
             .child(
                 h_flex()
+                    .w_full()
                     .gap_6()
                     .children(group.examples.into_iter().map(Self::render_example))
                     .into_any_element(),
@@ -103,6 +113,7 @@ pub trait ComponentPreview: IntoElement {
         };
 
         base.gap_1()
+            .when(example.grow, |this| this.flex_1())
             .child(example.element)
             .child(
                 Label::new(example.variant_name)
@@ -117,6 +128,7 @@ pub trait ComponentPreview: IntoElement {
 pub struct ComponentExample<T> {
     variant_name: SharedString,
     element: T,
+    grow: bool,
 }
 
 impl<T> ComponentExample<T> {
@@ -125,14 +137,22 @@ impl<T> ComponentExample<T> {
         Self {
             variant_name: variant_name.into(),
             element: example,
+            grow: false,
         }
     }
+
+    /// Set the example to grow to fill the available horizontal space.
+    pub fn grow(mut self) -> Self {
+        self.grow = true;
+        self
+    }
 }
 
 /// A group of component examples.
 pub struct ComponentExampleGroup<T> {
     pub title: Option<SharedString>,
     pub examples: Vec<ComponentExample<T>>,
+    pub grow: bool,
 }
 
 impl<T> ComponentExampleGroup<T> {
@@ -141,15 +161,24 @@ impl<T> ComponentExampleGroup<T> {
         Self {
             title: None,
             examples,
+            grow: false,
         }
     }
 
+    /// Create a new group of examples with the given title.
     pub fn with_title(title: impl Into<SharedString>, examples: Vec<ComponentExample<T>>) -> Self {
         Self {
             title: Some(title.into()),
             examples,
+            grow: false,
         }
     }
+
+    /// Set the group to grow to fill the available horizontal space.
+    pub fn grow(mut self) -> Self {
+        self.grow = true;
+        self
+    }
 }
 
 /// Create a single example

crates/welcome/src/welcome.rs 🔗

@@ -267,13 +267,8 @@ impl Render for WelcomePage {
                             ),
                     )
                     .child(
-                        v_flex()
-                            .p_3()
+                        v_group()
                             .gap_2()
-                            .bg(cx.theme().colors().element_background)
-                            .border_1()
-                            .border_color(cx.theme().colors().border_variant)
-                            .rounded_md()
                             .child(CheckboxWithLabel::new(
                                 "enable-vim",
                                 Label::new("Enable Vim Mode"),

crates/workspace/src/theme_preview.rs 🔗

@@ -5,8 +5,8 @@ use theme::all_theme_colors;
 use ui::{
     element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio, AudioStatus,
     Availability, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
-    Checkbox, CheckboxWithLabel, DecoratedIcon, ElevationIndex, Facepile, IconDecoration,
-    Indicator, Table, TintColor, Tooltip,
+    Checkbox, CheckboxWithLabel, ContentGroup, DecoratedIcon, ElevationIndex, Facepile,
+    IconDecoration, Indicator, Table, TintColor, Tooltip,
 };
 
 use crate::{Item, Workspace};
@@ -510,6 +510,7 @@ impl ThemePreview {
             .overflow_scroll()
             .size_full()
             .gap_2()
+            .child(ContentGroup::render_component_previews(cx))
             .child(IconDecoration::render_component_previews(cx))
             .child(DecoratedIcon::render_component_previews(cx))
             .child(Checkbox::render_component_previews(cx))