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>,
|
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()))
|
||||||
}),
|
}),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue