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>,
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<Self>) -> 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()))
}),
)),
)

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::{
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<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
active: bool,
default_open: bool,
click_to_open: bool,
collapsed: bool,
children: Vec<Self>,
suffix: Option<AnyElement>,
@ -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<Item = impl Into<Self>>) -> 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")

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
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