From c06e91101e1b410643d68caef534dfbfad0a6927 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 6 Nov 2025 10:57:18 +0800 Subject: [PATCH] collapsible: Add Collapsible. (#1525) Close #1479 image --- crates/story/src/collapsible_story.rs | 182 ++++++++++++++++++++++++++ crates/story/src/lib.rs | 2 + crates/story/src/main.rs | 1 + crates/ui/src/collapsible.rs | 80 +++++++++++ crates/ui/src/lib.rs | 1 + docs/docs/components/collapsible.md | 57 ++++++++ docs/docs/components/index.md | 1 + 7 files changed, 324 insertions(+) create mode 100644 crates/story/src/collapsible_story.rs create mode 100644 crates/ui/src/collapsible.rs create mode 100644 docs/docs/components/collapsible.md diff --git a/crates/story/src/collapsible_story.rs b/crates/story/src/collapsible_story.rs new file mode 100644 index 00000000..0df53a3a --- /dev/null +++ b/crates/story/src/collapsible_story.rs @@ -0,0 +1,182 @@ +use gpui::div; +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, + Styled, Window, prelude::FluentBuilder as _, +}; + +use gpui_component::group_box::GroupBox; +use gpui_component::label::Label; +use gpui_component::tag::Tag; +use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex}; +use gpui_component::{ + Sizable, + button::{Button, ButtonVariants}, + collapsible::Collapsible, + v_flex, +}; + +use crate::section; + +pub struct CollapsibleStory { + focus_handle: FocusHandle, + item1_open: bool, + item2_open: bool, +} + +impl super::Story for CollapsibleStory { + fn title() -> &'static str { + "Collapsible" + } + + fn description() -> &'static str { + "An interactive element that expands/collapses." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl CollapsibleStory { + pub(crate) fn new(_: &mut Window, cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + item1_open: false, + item2_open: false, + } + } + + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl Focusable for CollapsibleStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CollapsibleStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let items = [ + ["TSLA.US", "$423.00", "+30.25%"], + ["NVDA.US", "$312.00", "+12.12%"], + ["AAPL.US", "$145.00", "-8.50%"], + ]; + + v_flex() + .gap_6() + .child( + section("Expland Paragraphs").v_flex().child( + Collapsible::new() + .max_w_128() + .gap_1() + .open(self.item1_open) + .child( + "This is a collapsible component. \ + Click the header to expand or collapse the content.", + ) + .content( + "This is the full content of the Collapsible component. \ + It is only visible when the component is expanded. \n\ + You can put any content you like here, including text, images, \ + or other UI elements. + ", + ) + .child( + h_flex().justify_center().child( + Button::new("toggle1") + .icon(IconName::ChevronDown) + .label("Show more") + .when(self.item1_open, |this| { + this.icon(IconName::ChevronUp).label("Show less") + }) + .xsmall() + .link() + .on_click({ + cx.listener(move |this, _, _, cx| { + this.item1_open = !this.item1_open; + cx.notify(); + }) + }), + ), + ), + ), + ) + .child( + section("Card").child( + GroupBox::new() + .outline() + .w_80() + .title("Collapsible in a Card") + .child( + Collapsible::new() + .gap_1() + .open(self.item2_open) + .child( + h_flex() + .justify_between() + .child( + v_flex().child("Total Return").child( + h_flex() + .gap_1() + .child( + Label::new("123.5%") + .text_2xl() + .font_semibold(), + ) + .child( + Tag::info() + .child("+4.5%") + .outline() + .rounded_full() + .small(), + ), + ), + ) + .child( + Button::new("toggle2") + .small() + .outline() + .icon(IconName::ChevronDown) + .label("Details") + .when(self.item2_open, |this| { + this.icon(IconName::ChevronUp) + }) + .on_click({ + cx.listener(move |this, _, _, cx| { + this.item2_open = !this.item2_open; + cx.notify(); + }) + }), + ), + ) + .content(v_flex().gap_2().children(items.iter().map(|item| { + let is_up = item[2].starts_with('+'); + + h_flex().justify_between().child(item[0]).child( + h_flex() + .flex_1() + .justify_end() + .gap_4() + .child(div().w_16().justify_end().child(item[1])) + .child( + Label::new(item[2]) + .text_xs() + .w_16() + .justify_end() + .when(is_up, |this| { + this.text_color(cx.theme().green) + }) + .when(!is_up, |this| { + this.text_color(cx.theme().red) + }), + ), + ) + }))), + ), + ), + ) + } +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 3c465744..d892ee2c 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -9,6 +9,7 @@ mod calendar_story; mod chart_story; mod checkbox_story; mod clipboard_story; +mod collapsible_story; mod color_picker_story; mod date_picker_story; mod description_list_story; @@ -68,6 +69,7 @@ pub use calendar_story::CalendarStory; pub use chart_story::ChartStory; pub use checkbox_story::CheckboxStory; pub use clipboard_story::ClipboardStory; +pub use collapsible_story::CollapsibleStory; pub use color_picker_story::ColorPickerStory; pub use date_picker_story::DatePickerStory; pub use description_list_story::DescriptionListStory; diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index bae65ce8..04493b6d 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -45,6 +45,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/ui/src/collapsible.rs b/crates/ui/src/collapsible.rs new file mode 100644 index 00000000..2e346db8 --- /dev/null +++ b/crates/ui/src/collapsible.rs @@ -0,0 +1,80 @@ +use gpui::{ + AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, +}; + +use crate::{v_flex, StyledExt}; + +enum CollapsibleChild { + Element(AnyElement), + Content(AnyElement), +} + +impl CollapsibleChild { + fn is_content(&self) -> bool { + matches!(self, CollapsibleChild::Content(_)) + } +} + +/// An interactive element which expands/collapses. +#[derive(IntoElement)] +pub struct Collapsible { + style: StyleRefinement, + children: Vec, + open: bool, +} + +impl Collapsible { + /// Creates a new `Collapsible` instance. + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + open: false, + children: vec![], + } + } + + /// Sets whether the collapsible is open. default is false. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Sets the content of the collapsible. + /// + /// If `open` is false, content will be hidden. + pub fn content(mut self, content: impl IntoElement) -> Self { + self.children + .push(CollapsibleChild::Content(content.into_any_element())); + self + } +} + +impl Styled for Collapsible { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl ParentElement for Collapsible { + fn extend(&mut self, elements: impl IntoIterator) { + self.children + .extend(elements.into_iter().map(|el| CollapsibleChild::Element(el))); + } +} + +impl RenderOnce for Collapsible { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + v_flex() + .refine_style(&self.style) + .children(self.children.into_iter().filter_map(|child| { + if child.is_content() && !self.open { + None + } else { + match child { + CollapsibleChild::Element(el) => Some(el), + CollapsibleChild::Content(el) => Some(el), + } + } + })) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1a1bde95..bfacd394 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -26,6 +26,7 @@ pub mod button; pub mod chart; pub mod checkbox; pub mod clipboard; +pub mod collapsible; pub mod color_picker; pub mod description_list; pub mod divider; diff --git a/docs/docs/components/collapsible.md b/docs/docs/components/collapsible.md new file mode 100644 index 00000000..502d4f17 --- /dev/null +++ b/docs/docs/components/collapsible.md @@ -0,0 +1,57 @@ +--- +title: Collapsible +description: An interactive element which expands/collapses. +--- + +# Collapsible + +An interactive element which expands/collapses. + +## Import + +```rust +use gpui_component::collapsible::Collapsible; +``` + +## Usage + +### Basic Use + +```rust +Collapsible::new() + .max_w_128() + .gap_1() + .open(self.open) + .child( + "This is a collapsible component. \ + Click the header to expand or collapse the content.", + ) + .content( + "This is the full content of the Collapsible component. \ + It is only visible when the component is expanded. \n\ + You can put any content you like here, including text, images, \ + or other UI elements.", + ) + .child( + h_flex().justify_center().child( + Button::new("toggle1") + .icon(IconName::ChevronDown) + .label("Show more") + .when(open, |this| { + this.icon(IconName::ChevronUp).label("Show less") + }) + .xsmall() + .link() + .on_click({ + cx.listener(move |this, _, _, cx| { + this.open = !this.open; + cx.notify(); + }) + }), + ), + ) +``` + +We can use `open` method to control the collapsed state. If false, the `content` method added child elements will be hidden. + +[Collapsible]: https://docs.rs/gpui-component/latest/gpui_component/collapsible/struct.Collapsible.html diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index 2347ed95..101350bc 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -14,6 +14,7 @@ collapsed: false - [Badge](badge) - Count badges and indicators - [Button](button) - Interactive buttons with multiple variants - [Checkbox](checkbox) - Binary selection control +- [Collapsible](collapsible) - Expandable/collapsible content - [Icon](icon) - Icon display component - [Image](image) - Image display with fallbacks - [Indicator](indicator) - Loading and status indicators