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:
parent
9d11418bd1
commit
96c34a9fb0
3 changed files with 103 additions and 122 deletions
|
|
@ -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()))
|
||||
}),
|
||||
)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue