Add checkboxes and their stories

Nate Butler created

Change summary

assets/icons/dash.svg                   |   1 
crates/storybook2/src/story_selector.rs |   2 
crates/theme2/src/colors.rs             |   2 
crates/theme2/src/default_colors.rs     |   4 
crates/ui2/docs/building-ui.md          |  10 +
crates/ui2/src/components.rs            |   2 
crates/ui2/src/components/checkbox.rs   | 217 +++++++++++++++++++++++++++
crates/ui2/src/components/icon.rs       |   4 
crates/ui2/src/prelude.rs               |   6 
9 files changed, 245 insertions(+), 3 deletions(-)

Detailed changes

assets/icons/dash.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><path d="M5 12h14"/></svg>

crates/storybook2/src/story_selector.rs 🔗

@@ -19,6 +19,7 @@ pub enum ComponentStory {
     Buffer,
     Button,
     ChatPanel,
+    Checkbox,
     CollabPanel,
     Colors,
     CommandPalette,
@@ -61,6 +62,7 @@ impl ComponentStory {
             Self::Buffer => cx.build_view(|_| ui::BufferStory).into(),
             Self::Button => cx.build_view(|_| ButtonStory).into(),
             Self::ChatPanel => cx.build_view(|_| ui::ChatPanelStory).into(),
+            Self::Checkbox => cx.build_view(|_| ui::CheckboxStory).into(),
             Self::CollabPanel => cx.build_view(|_| ui::CollabPanelStory).into(),
             Self::Colors => cx.build_view(|_| ColorsStory).into(),
             Self::CommandPalette => cx.build_view(|_| ui::CommandPaletteStory).into(),

crates/theme2/src/colors.rs 🔗

@@ -54,7 +54,9 @@ pub struct ThemeColors {
     pub border: Hsla,
     pub border_variant: Hsla,
     pub border_focused: Hsla,
+    pub border_selected: Hsla,
     pub border_transparent: Hsla,
+    pub border_disabled: Hsla,
     pub elevated_surface: Hsla,
     pub surface: Hsla,
     pub background: Hsla,

crates/theme2/src/default_colors.rs 🔗

@@ -205,6 +205,8 @@ impl ThemeColors {
             border: neutral().light().step_6(),
             border_variant: neutral().light().step_5(),
             border_focused: blue().light().step_5(),
+            border_disabled: neutral().light().step_3(),
+            border_selected: blue().light().step_5(),
             border_transparent: system.transparent,
             elevated_surface: neutral().light().step_2(),
             surface: neutral().light().step_2(),
@@ -250,6 +252,8 @@ impl ThemeColors {
             border: neutral().dark().step_6(),
             border_variant: neutral().dark().step_5(),
             border_focused: blue().dark().step_5(),
+            border_disabled: neutral().dark().step_3(),
+            border_selected: blue().dark().step_5(),
             border_transparent: system.transparent,
             elevated_surface: neutral().dark().step_2(),
             surface: neutral().dark().step_2(),

crates/ui2/docs/building-ui.md 🔗

@@ -2,6 +2,16 @@
 
 ## Common patterns
 
+### Method ordering
+
+- id
+- Flex properties
+- Position properties
+- Size properties
+- Style properties
+- Handlers
+- State properties
+
 ### Using the Label Component to Create UI Text
 
 The `Label` component helps in displaying text on user interfaces. It creates an interface where specific parameters such as label color, line height style, and strikethrough can be set.

crates/ui2/src/components.rs 🔗

@@ -1,5 +1,6 @@
 mod avatar;
 mod button;
+mod checkbox;
 mod context_menu;
 mod details;
 mod facepile;
@@ -25,6 +26,7 @@ mod tool_divider;
 
 pub use avatar::*;
 pub use button::*;
+pub use checkbox::*;
 pub use context_menu::*;
 pub use details::*;
 pub use facepile::*;

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

@@ -0,0 +1,217 @@
+///! # Checkbox
+///!
+///! Checkboxes are used for multiple choices, not for mutually exclusive choices.
+///! Each checkbox works independently from other checkboxes in the list,
+///! therefore checking an additional box does not affect any other selections.
+use gpui2::{
+    div, Component, ParentElement, SharedString, StatelessInteractive, Styled, ViewContext,
+};
+use theme2::ActiveTheme;
+
+use crate::{Icon, IconColor, IconElement, Selected};
+
+#[derive(Component)]
+pub struct Checkbox {
+    id: SharedString,
+    checked: Selected,
+    disabled: bool,
+}
+
+impl Checkbox {
+    pub fn new(id: impl Into<SharedString>) -> Self {
+        Self {
+            id: id.into(),
+            checked: Selected::Unselected,
+            disabled: false,
+        }
+    }
+
+    pub fn toggle(mut self) -> Self {
+        self.checked = match self.checked {
+            Selected::Selected => Selected::Unselected,
+            Selected::Unselected => Selected::Selected,
+            Selected::Indeterminate => Selected::Selected,
+        };
+        self
+    }
+
+    pub fn set_indeterminate(mut self) -> Self {
+        self.checked = Selected::Indeterminate;
+        self
+    }
+
+    pub fn set_disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+
+    pub fn render<V: 'static>(self, _view: &mut V, cx: &mut ViewContext<V>) -> impl Component<V> {
+        let group_id = format!("checkbox_group_{}", self.id);
+
+        // The icon is different depending on the state of the checkbox.
+        //
+        // We need the match to return all the same type,
+        // so we wrap the eatch result in a div.
+        //
+        // We are still exploring the best way to handle this.
+        let icon = match self.checked {
+            // When selected, we show a checkmark.
+            Selected::Selected => {
+                div().child(
+                    IconElement::new(Icon::Check)
+                        .size(crate::IconSize::Small)
+                        .color(
+                            // If the checkbox is disabled we change the color of the icon.
+                            if self.disabled {
+                                IconColor::Disabled
+                            } else {
+                                IconColor::Selected
+                            },
+                        ),
+                )
+            }
+            // In an indeterminate state, we show a dash.
+            Selected::Indeterminate => {
+                div().child(
+                    IconElement::new(Icon::Dash)
+                        .size(crate::IconSize::Small)
+                        .color(
+                            // If the checkbox is disabled we change the color of the icon.
+                            if self.disabled {
+                                IconColor::Disabled
+                            } else {
+                                IconColor::Selected
+                            },
+                        ),
+                )
+            }
+            // When unselected, we show nothing.
+            Selected::Unselected => div(),
+        };
+
+        // A checkbox could be in an indeterminate state,
+        // for example the indeterminate state could represent:
+        //  - a group of options of which only some are selected
+        //  - an enabled option that is no longer available
+        //  - a previously agreed to license that has been updated
+        //
+        // For the sake of styles we treat the indeterminate state as selected,
+        // but it's icon will be different.
+        let selected =
+            self.checked == Selected::Selected || self.checked == Selected::Indeterminate;
+
+        // We could use something like this to make the checkbox background when selected:
+        //
+        // ~~~rust
+        // ...
+        // .when(selected, |this| {
+        //     this.bg(cx.theme().colors().element_selected)
+        // })
+        // ~~~
+        //
+        // But we use a match instead here because the checkbox might be disabled,
+        // and it could be disabled _while_ it is selected, as well as while it is not selected.
+        let (bg_color, border_color) = match (self.disabled, selected) {
+            (true, _) => (
+                cx.theme().colors().ghost_element_disabled,
+                cx.theme().colors().border_disabled,
+            ),
+            (false, true) => (
+                cx.theme().colors().element_selected,
+                cx.theme().colors().border,
+            ),
+            (false, false) => (cx.theme().colors().element, cx.theme().colors().border),
+        };
+
+        div()
+            // Rather than adding `px_1()` to add some space around the checkbox,
+            // we use a larger parent element to create a slightly larger
+            // click area for the checkbox.
+            .size_5()
+            // Because we've enlarged the click area, we need to create a
+            // `group` to pass down interaction events to the checkbox.
+            .group(group_id.clone())
+            .child(
+                div()
+                    .flex()
+                    // This prevent the flex element from growing
+                    // or shrinking in response to any size changes
+                    .flex_none()
+                    // The combo of `justify_center()` and `items_center()`
+                    // is used frequently to center elements in a flex container.
+                    //
+                    // We use this to center the icon in the checkbox.
+                    .justify_center()
+                    .items_center()
+                    .m_1()
+                    .size_4()
+                    .rounded_sm()
+                    .bg(bg_color)
+                    .border()
+                    .border_color(border_color)
+                    // We only want the interaction states to fire when we
+                    // are in a checkbox that isn't disabled.
+                    .when(!self.disabled, |this| {
+                        // Here instead of `hover()` we use `group_hover()`
+                        // to pass it the group id.
+                        this.group_hover(group_id.clone(), |el| {
+                            el.bg(cx.theme().colors().element_hover)
+                        })
+                    })
+                    .child(icon),
+            )
+    }
+}
+
+#[cfg(feature = "stories")]
+pub use stories::*;
+
+#[cfg(feature = "stories")]
+mod stories {
+    use super::*;
+    use crate::{h_stack, Story};
+    use gpui2::{Div, Render};
+
+    pub struct CheckboxStory;
+
+    impl Render for CheckboxStory {
+        type Element = Div<Self>;
+
+        fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+            Story::container(cx)
+                .child(Story::title_for::<_, Checkbox>(cx))
+                .child(Story::label(cx, "Default"))
+                .child(
+                    h_stack()
+                        .p_2()
+                        .gap_2()
+                        .rounded_md()
+                        .border()
+                        .border_color(cx.theme().colors().border)
+                        .child(Checkbox::new("checkbox-enabled"))
+                        .child(Checkbox::new("checkbox-intermediate").set_indeterminate())
+                        .child(Checkbox::new("checkbox-selected").toggle()),
+                )
+                .child(Story::label(cx, "Disabled"))
+                .child(
+                    h_stack()
+                        .p_2()
+                        .gap_2()
+                        .rounded_md()
+                        .border()
+                        .border_color(cx.theme().colors().border)
+                        .child(Checkbox::new("checkbox-disabled").set_disabled(true))
+                        .child(
+                            Checkbox::new("checkbox-disabled-intermediate")
+                                .set_disabled(true)
+                                .set_indeterminate(),
+                        )
+                        .child(
+                            Checkbox::new("checkbox-disabled-selected")
+                                .set_disabled(true)
+                                .toggle(),
+                        ),
+                )
+        }
+    }
+}

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

@@ -22,6 +22,7 @@ pub enum IconColor {
     Warning,
     Success,
     Info,
+    Selected,
 }
 
 impl IconColor {
@@ -36,6 +37,7 @@ impl IconColor {
             IconColor::Warning => cx.theme().status().warning,
             IconColor::Success => cx.theme().status().success,
             IconColor::Info => cx.theme().status().info,
+            IconColor::Selected => cx.theme().colors().icon_accent,
         }
     }
 }
@@ -55,6 +57,7 @@ pub enum Icon {
     ChevronRight,
     ChevronUp,
     Close,
+    Dash,
     Exit,
     ExclamationTriangle,
     File,
@@ -112,6 +115,7 @@ impl Icon {
             Icon::ChevronRight => "icons/chevron_right.svg",
             Icon::ChevronUp => "icons/chevron_up.svg",
             Icon::Close => "icons/x.svg",
+            Icon::Dash => "icons/dash.svg",
             Icon::Exit => "icons/exit.svg",
             Icon::ExclamationTriangle => "icons/warning.svg",
             Icon::File => "icons/file.svg",

crates/ui2/src/prelude.rs 🔗

@@ -154,10 +154,10 @@ impl InteractionState {
     }
 }
 
-#[derive(Default, PartialEq)]
-pub enum SelectedState {
+#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)]
+pub enum Selected {
     #[default]
     Unselected,
-    PartiallySelected,
+    Indeterminate,
     Selected,
 }