sidebar: Improve Sidebar to allows caret icon to expand submenu. (#1642)

https://github.com/user-attachments/assets/7cd4ef52-e633-4db4-bdba-2912bba2120d
This commit is contained in:
Jason Lee 2025-11-19 13:39:24 +08:00 committed by GitHub
parent 9d11418bd1
commit 96c34a9fb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 103 additions and 122 deletions

View file

@ -31,6 +31,7 @@ pub struct SidebarStory {
active_subitem: Option<SubItem>, active_subitem: Option<SubItem>,
collapsed: bool, collapsed: bool,
side: Side, side: Side,
click_to_open_submenu: bool,
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
checked: bool, checked: bool,
} }
@ -52,20 +53,32 @@ impl SidebarStory {
side: Side::Left, side: Side::Left,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
checked: false, checked: false,
click_to_open_submenu: false,
} }
} }
fn render_content(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render_content(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().child( v_flex().gap_3().child(
h_flex().gap_2().child( h_flex()
Switch::new("side") .gap_3()
.label("Placement Right") .child(
.checked(self.side.is_right()) Switch::new("side")
.on_click(cx.listener(|this, checked: &bool, _, cx| { .label("Placement Right")
this.side = if *checked { Side::Right } else { Side::Left }; .checked(self.side.is_right())
cx.notify(); .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.last_active_item = item;
this.active_subitem = None;
cx.notify(); cx.notify();
} }
} }
@ -310,10 +324,14 @@ impl Render for SidebarStory {
) )
.child( .child(
SidebarGroup::new("Platform").child(SidebarMenu::new().children( 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()) SidebarMenuItem::new(item.label())
.icon(item.icon()) .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( .children(item.items().into_iter().enumerate().map(
|(ix, sub_item)| { |(ix, sub_item)| {
SidebarMenuItem::new(sub_item.label()) SidebarMenuItem::new(sub_item.label())
@ -340,9 +358,12 @@ impl Render for SidebarStory {
.child( .child(
SidebarGroup::new("Projects").child(SidebarMenu::new().children( SidebarGroup::new("Projects").child(SidebarMenu::new().children(
groups[1].iter().enumerate().map(|(ix, item)| { groups[1].iter().enumerate().map(|(ix, item)| {
let is_active =
self.last_active_item == *item && self.active_subitem == None;
SidebarMenuItem::new(item.label()) SidebarMenuItem::new(item.label())
.icon(item.icon()) .icon(item.icon())
.active(self.last_active_item == *item) .active(is_active)
.click_to_open(self.click_to_open_submenu)
.when(ix == 0, |this| { .when(ix == 0, |this| {
this.suffix( this.suffix(
Badge::new().dot().count(1).child( Badge::new().dot().count(1).child(
@ -351,6 +372,7 @@ impl Render for SidebarStory {
) )
}) })
.when(ix == 1, |this| this.suffix(IconName::Settings2)) .when(ix == 1, |this| this.suffix(IconName::Settings2))
.on_click(cx.listener(item.handler()))
}), }),
)), )),
) )

View file

@ -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::{ use gpui::{
div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId,
InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
@ -70,6 +73,8 @@ pub struct SidebarMenuItem {
label: SharedString, label: SharedString,
handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>, handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
active: bool, active: bool,
default_open: bool,
click_to_open: bool,
collapsed: bool, collapsed: bool,
children: Vec<Self>, children: Vec<Self>,
suffix: Option<AnyElement>, suffix: Option<AnyElement>,
@ -85,6 +90,8 @@ impl SidebarMenuItem {
handler: Rc::new(|_, _, _| {}), handler: Rc::new(|_, _, _| {}),
active: false, active: false,
collapsed: false, collapsed: false,
default_open: false,
click_to_open: false,
children: Vec::new(), children: Vec::new(),
suffix: None, suffix: None,
} }
@ -117,6 +124,24 @@ impl SidebarMenuItem {
self 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<Item = impl Into<Self>>) -> Self { pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
self.children = children.into_iter().map(Into::into).collect(); self.children = children.into_iter().map(Into::into).collect();
self self
@ -137,23 +162,19 @@ impl SidebarMenuItem {
fn is_submenu(&self) -> bool { fn is_submenu(&self) -> bool {
self.children.len() > 0 self.children.len() > 0
} }
fn is_open(&self) -> bool {
if self.is_submenu() {
self.active
} else {
false
}
}
} }
impl RenderOnce for SidebarMenuItem { 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 handler = self.handler.clone();
let is_collapsed = self.collapsed; let is_collapsed = self.collapsed;
let is_active = self.active; let is_active = self.active;
let is_open = self.is_open();
let is_submenu = self.is_submenu(); let is_submenu = self.is_submenu();
let is_open = is_submenu && !is_collapsed && *open_state.read(cx);
div() div()
.id(self.id.clone()) .id(self.id.clone())
@ -176,7 +197,7 @@ impl RenderOnce for SidebarMenuItem {
this.bg(cx.theme().sidebar_accent.opacity(0.8)) this.bg(cx.theme().sidebar_accent.opacity(0.8))
.text_color(cx.theme().sidebar_accent_foreground) .text_color(cx.theme().sidebar_accent_foreground)
}) })
.when(is_active && !is_submenu, |this| { .when(is_active, |this| {
this.font_medium() this.font_medium()
.bg(cx.theme().sidebar_accent) .bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground) .text_color(cx.theme().sidebar_accent_foreground)
@ -206,15 +227,45 @@ impl RenderOnce for SidebarMenuItem {
) )
.when(is_submenu, |this| { .when(is_submenu, |this| {
this.child( this.child(
Icon::new(IconName::ChevronRight) Button::new("caret")
.size_4() .xsmall()
.when(is_open, |this| this.rotate(percentage(90. / 360.))), .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( this.child(
v_flex() v_flex()
.id("submenu") .id("submenu")

View file

@ -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 ## Theming
The sidebar uses dedicated theme colors: The sidebar uses dedicated theme colors:
@ -350,20 +272,6 @@ cx.theme().sidebar_primary // Primary elements
cx.theme().sidebar_primary_foreground // Primary text 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 ## Examples
### File Explorer Sidebar ### File Explorer Sidebar