diff --git a/crates/story/src/sidebar_story.rs b/crates/story/src/sidebar_story.rs index 08af5594..ebfb8f58 100644 --- a/crates/story/src/sidebar_story.rs +++ b/crates/story/src/sidebar_story.rs @@ -31,6 +31,7 @@ pub struct SidebarStory { active_subitem: Option, collapsed: bool, side: Side, + click_to_open_submenu: bool, focus_handle: gpui::FocusHandle, checked: bool, } @@ -52,20 +53,32 @@ impl SidebarStory { side: Side::Left, focus_handle: cx.focus_handle(), checked: false, + click_to_open_submenu: false, } } fn render_content(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex().child( - h_flex().gap_2().child( - Switch::new("side") - .label("Placement Right") - .checked(self.side.is_right()) - .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.side = if *checked { Side::Right } else { Side::Left }; - cx.notify(); - })), - ), + v_flex().gap_3().child( + h_flex() + .gap_3() + .child( + Switch::new("side") + .label("Placement Right") + .checked(self.side.is_right()) + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.side = if *checked { Side::Right } else { Side::Left }; + cx.notify(); + })), + ) + .child( + Switch::new("click-to-open") + .checked(self.click_to_open_submenu) + .label("Click to open submenu") + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.click_to_open_submenu = *checked; + cx.notify(); + })), + ), ) } } @@ -137,6 +150,7 @@ impl Item { } this.last_active_item = item; + this.active_subitem = None; cx.notify(); } } @@ -310,10 +324,14 @@ impl Render for SidebarStory { ) .child( SidebarGroup::new("Platform").child(SidebarMenu::new().children( - groups[0].iter().map(|item| { + groups[0].iter().enumerate().map(|(ix, item)| { + let is_active = + self.last_active_item == *item && self.active_subitem == None; SidebarMenuItem::new(item.label()) .icon(item.icon()) - .active(self.active_items.contains_key(item)) + .active(is_active) + .default_open(ix == 0) + .click_to_open(self.click_to_open_submenu) .children(item.items().into_iter().enumerate().map( |(ix, sub_item)| { SidebarMenuItem::new(sub_item.label()) @@ -340,9 +358,12 @@ impl Render for SidebarStory { .child( SidebarGroup::new("Projects").child(SidebarMenu::new().children( groups[1].iter().enumerate().map(|(ix, item)| { + let is_active = + self.last_active_item == *item && self.active_subitem == None; SidebarMenuItem::new(item.label()) .icon(item.icon()) - .active(self.last_active_item == *item) + .active(is_active) + .click_to_open(self.click_to_open_submenu) .when(ix == 0, |this| { this.suffix( Badge::new().dot().count(1).child( @@ -351,6 +372,7 @@ impl Render for SidebarStory { ) }) .when(ix == 1, |this| this.suffix(IconName::Settings2)) + .on_click(cx.listener(item.handler())) }), )), ) diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index fb4a747c..0d535d98 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -1,4 +1,7 @@ -use crate::{h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, StyledExt}; +use crate::{ + button::{Button, ButtonVariants as _}, + h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, Sizable as _, StyledExt, +}; use gpui::{ div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, @@ -70,6 +73,8 @@ pub struct SidebarMenuItem { label: SharedString, handler: Rc, active: bool, + default_open: bool, + click_to_open: bool, collapsed: bool, children: Vec, suffix: Option, @@ -85,6 +90,8 @@ impl SidebarMenuItem { handler: Rc::new(|_, _, _| {}), active: false, collapsed: false, + default_open: false, + click_to_open: false, children: Vec::new(), suffix: None, } @@ -117,6 +124,24 @@ impl SidebarMenuItem { self } + /// Set the default open state of the Submenu, default is `false`. + /// + /// This only used on initial render, the internal state will be used afterwards. + pub fn default_open(mut self, open: bool) -> Self { + self.default_open = open; + self + } + + /// Set whether clicking the menu item open the submenu. + /// + /// Default is `false`. + /// + /// If `false` we only handle open/close via the caret button. + pub fn click_to_open(mut self, click_to_open: bool) -> Self { + self.click_to_open = click_to_open; + self + } + pub fn children(mut self, children: impl IntoIterator>) -> Self { self.children = children.into_iter().map(Into::into).collect(); self @@ -137,23 +162,19 @@ impl SidebarMenuItem { fn is_submenu(&self) -> bool { self.children.len() > 0 } - - fn is_open(&self) -> bool { - if self.is_submenu() { - self.active - } else { - false - } - } } impl RenderOnce for SidebarMenuItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let click_to_open = self.click_to_open; + let default_open = self.default_open; + let open_state = window.use_keyed_state(self.id.clone(), cx, |_, _| default_open); + let handler = self.handler.clone(); let is_collapsed = self.collapsed; let is_active = self.active; - let is_open = self.is_open(); let is_submenu = self.is_submenu(); + let is_open = is_submenu && !is_collapsed && *open_state.read(cx); div() .id(self.id.clone()) @@ -176,7 +197,7 @@ impl RenderOnce for SidebarMenuItem { this.bg(cx.theme().sidebar_accent.opacity(0.8)) .text_color(cx.theme().sidebar_accent_foreground) }) - .when(is_active && !is_submenu, |this| { + .when(is_active, |this| { this.font_medium() .bg(cx.theme().sidebar_accent) .text_color(cx.theme().sidebar_accent_foreground) @@ -206,15 +227,45 @@ impl RenderOnce for SidebarMenuItem { ) .when(is_submenu, |this| { this.child( - Icon::new(IconName::ChevronRight) - .size_4() - .when(is_open, |this| this.rotate(percentage(90. / 360.))), + Button::new("caret") + .xsmall() + .ghost() + .icon( + Icon::new(IconName::ChevronRight) + .size_4() + .when(is_open, |this| { + this.rotate(percentage(90. / 360.)) + }), + ) + .on_click({ + let open_state = open_state.clone(); + move |_, _, cx| { + // Avoid trigger item click, just expand/collapse submenu + cx.stop_propagation(); + open_state.update(cx, |is_open, cx| { + *is_open = !*is_open; + cx.notify(); + }) + } + }), ) }) }) - .on_click(move |ev, window, cx| handler(ev, window, cx)), + .on_click({ + let open_state = open_state.clone(); + move |ev, window, cx| { + if click_to_open { + open_state.update(cx, |is_open, cx| { + *is_open = true; + cx.notify(); + }); + } + + handler(ev, window, cx) + } + }), ) - .when(is_submenu && is_open && !is_collapsed, |this| { + .when(is_open, |this| { this.child( v_flex() .id("submenu") diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md index dc226b15..d1f3e017 100644 --- a/docs/docs/components/sidebar.md +++ b/docs/docs/components/sidebar.md @@ -257,84 +257,6 @@ Sidebar::new(Side::Left) ) ``` -## API Reference - -### Sidebar - -| Method | Description | -| ------------------- | --------------------------------------------------- | -| `new(side)` | Create a sidebar on the specified side (Left/Right) | -| `left()` | Create a left-side sidebar | -| `right()` | Create a right-side sidebar | -| `width(px)` | Set sidebar width (default: 255px) | -| `border_width(px)` | Set border width (default: 1px) | -| `collapsible(bool)` | Make sidebar collapsible (default: true) | -| `collapsed(bool)` | Set collapsed state | -| `header(element)` | Set header content | -| `footer(element)` | Set footer content | -| `child(element)` | Add child element (must implement Collapsible) | -| `children(iter)` | Add multiple children | - -### SidebarHeader - -| Method | Description | -| ---------------- | --------------------------- | -| `new()` | Create a new sidebar header | -| `selected(bool)` | Set selected state | -| `child(element)` | Add child element | - -Implements: `Selectable`, `Collapsible`, `ParentElement`, `Styled`, `InteractiveElement`, `DropdownMenu` - -### SidebarFooter - -| Method | Description | -| ---------------- | --------------------------- | -| `new()` | Create a new sidebar footer | -| `selected(bool)` | Set selected state | -| `child(element)` | Add child element | - -Implements: `Selectable`, `Collapsible`, `ParentElement`, `Styled`, `InteractiveElement`, `DropdownMenu` - -### SidebarGroup - -| Method | Description | -| ----------------- | --------------------------- | -| `new(label)` | Create a group with a label | -| `child(element)` | Add child element | -| `children(iter)` | Add multiple children | -| `collapsed(bool)` | Set collapsed state | - -### SidebarMenu - -| Method | Description | -| ----------------- | ----------------------- | -| `new()` | Create a new menu | -| `child(item)` | Add menu item | -| `children(iter)` | Add multiple menu items | -| `collapsed(bool)` | Set collapsed state | - -### SidebarMenuItem - -| Method | Description | -| ----------------- | ---------------------------------------- | -| `new(label)` | Create a menu item with label | -| `icon(icon)` | Set icon | -| `active(bool)` | Set active state | -| `on_click(fn)` | Set click handler | -| `children(iter)` | Add submenu items | -| `suffix(element)` | Add suffix element (badge, switch, etc.) | -| `collapsed(bool)` | Set collapsed state | - -### SidebarToggleButton - -| Method | Description | -| ----------------- | ------------------------------- | -| `left()` | Create toggle for left sidebar | -| `right()` | Create toggle for right sidebar | -| `side(side)` | Set sidebar side | -| `collapsed(bool)` | Set collapsed state | -| `on_click(fn)` | Set click handler | - ## Theming The sidebar uses dedicated theme colors: @@ -350,20 +272,6 @@ cx.theme().sidebar_primary // Primary elements cx.theme().sidebar_primary_foreground // Primary text ``` -### Custom Theme Colors - -```json -{ - "sidebar.background": "#fafafa", - "sidebar.foreground": "#171717", - "sidebar.border": "#e5e5e5", - "sidebar.accent.background": "#e5e5e5", - "sidebar.accent.foreground": "#171717", - "sidebar.primary.background": "#171717", - "sidebar.primary.foreground": "#fafafa" -} -``` - ## Examples ### File Explorer Sidebar