use crate::{Button, Popover, gpui_compat::element_id};
use gpui::{
AnyElement, App, Component, Hsla, IntoElement, MouseButton, RenderOnce, SharedString, Window,
div, prelude::*, px,
};
use liora_core::{Config, Placement, clear_popover, stable_unique_id};
use liora_icons::Icon;
use liora_icons_lucide::IconName;
use liora_theme::{ButtonSize, ButtonVariant};
use std::sync::Arc;
type DropdownButtonCallback = Arc<dyn Fn(&mut Window, &mut App) + 'static>;
#[derive(Clone)]
pub struct DropdownButtonItem {
pub label: SharedString,
pub icon: Option<IconName>,
pub disabled: bool,
pub danger: bool,
pub on_click: DropdownButtonCallback,
}
impl DropdownButtonItem {
pub fn new(
label: impl Into<SharedString>,
on_click: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
label: label.into(),
icon: None,
disabled: false,
danger: false,
on_click: Arc::new(on_click),
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn danger(mut self) -> Self {
self.danger = true;
self
}
}
pub struct DropdownButton {
label: SharedString,
items: Vec<DropdownButtonItem>,
placement: Placement,
close_on_click_outside: bool,
close_on_escape: bool,
id: Option<SharedString>,
variant: ButtonVariant,
size: ButtonSize,
secondary: bool,
disabled: bool,
split: bool,
leading_icon: Option<IconName>,
on_click: Option<DropdownButtonCallback>,
}
impl DropdownButton {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
items: Vec::new(),
placement: Placement::BottomStart,
close_on_click_outside: true,
close_on_escape: true,
id: None,
variant: ButtonVariant::Default,
size: ButtonSize::Default,
secondary: false,
disabled: false,
split: false,
leading_icon: None,
on_click: None,
}
}
pub fn item(
mut self,
label: impl Into<SharedString>,
on_click: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.items.push(DropdownButtonItem::new(label, on_click));
self
}
pub fn menu_item(mut self, item: DropdownButtonItem) -> Self {
self.items.push(item);
self
}
pub fn disabled_item(mut self, label: impl Into<SharedString>) -> Self {
self.items
.push(DropdownButtonItem::new(label, |_, _| {}).disabled(true));
self
}
pub fn danger_item(
mut self,
label: impl Into<SharedString>,
on_click: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.items
.push(DropdownButtonItem::new(label, on_click).danger());
self
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = Some(id.into());
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn close_on_escape(mut self, close: bool) -> Self {
self.close_on_escape = close;
self
}
pub fn close_on_click_outside(mut self, close: bool) -> Self {
self.close_on_click_outside = close;
self
}
pub fn primary(mut self) -> Self {
self.variant = ButtonVariant::Primary;
self
}
pub fn info(mut self) -> Self {
self.variant = ButtonVariant::Info;
self
}
pub fn success(mut self) -> Self {
self.variant = ButtonVariant::Success;
self
}
pub fn warning(mut self) -> Self {
self.variant = ButtonVariant::Warning;
self
}
pub fn danger(mut self) -> Self {
self.variant = ButtonVariant::Danger;
self
}
pub fn small(mut self) -> Self {
self.size = ButtonSize::Small;
self
}
pub fn large(mut self) -> Self {
self.size = ButtonSize::Large;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn secondary(mut self) -> Self {
self.secondary = true;
self
}
pub fn icon_start(mut self, icon: IconName) -> Self {
self.leading_icon = Some(icon);
self
}
pub fn split(mut self, split: bool) -> Self {
self.split = split;
self
}
pub fn on_click(mut self, cb: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Arc::new(cb));
self
}
fn resolved_button(&self, label: impl Into<SharedString>) -> Button {
let mut button = Button::new(label).variant(self.variant).size(self.size);
if self.secondary {
button = button.secondary();
}
if self.disabled {
button = button.disabled(true);
}
button
}
fn menu_trigger(&self, id: &SharedString) -> AnyElement {
if self.split {
split_trigger(self, id)
} else {
let mut button = self
.resolved_button(self.label.clone())
.icon_end(IconName::ChevronDown);
if let Some(icon) = self.leading_icon {
button = button.icon_start(icon);
}
if self.disabled {
button.into_any_element()
} else {
button.into_any_element()
}
}
}
}
impl RenderOnce for DropdownButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let dropdown_id = self.id.clone().unwrap_or_else(|| {
stable_unique_id(
format!(
"dropdown-button:{}:{}:{}:{:?}:{:?}",
self.label,
self.items.len(),
self.split,
self.variant,
self.size
),
"dropdown-button",
window,
cx,
)
});
let trigger = self.menu_trigger(&dropdown_id);
if self.disabled {
return trigger;
}
let items = self.items;
let disabled = self.disabled;
Popover::new(trigger)
.id(dropdown_id.clone())
.placement(self.placement)
.offset(px(4.0))
.flush_content()
.close_on_click_outside(self.close_on_click_outside)
.close_on_escape(self.close_on_escape)
.content(move |_window, _cx| {
dropdown_button_menu(dropdown_id.clone(), items.clone(), theme.clone(), disabled)
})
.into_any_element()
}
}
impl IntoElement for DropdownButton {
type Element = Component<Self>;
fn into_element(self) -> Self::Element {
Component::new(self)
}
}
fn split_trigger(button: &DropdownButton, id: &SharedString) -> AnyElement {
let main_id = element_id(format!("{id}-main-action"));
let toggle_id = element_id(format!("{id}-toggle"));
let mut primary = button.resolved_button(button.label.clone()).id(main_id);
if let Some(icon) = button.leading_icon {
primary = primary.icon_start(icon);
}
if !button.disabled
&& let Some(on_click) = button.on_click.clone()
{
primary = primary.on_click(move |_, window, cx| {
on_click(window, cx);
cx.stop_propagation();
});
}
let toggle = button
.resolved_button("")
.id(toggle_id)
.icon_only(IconName::ChevronDown);
div()
.id(element_id(format!("{id}-split-trigger")))
.flex()
.flex_row()
.items_center()
.gap_1()
.child(primary)
.child(toggle)
.into_any_element()
}
fn dropdown_button_menu(
dropdown_id: SharedString,
items: Vec<DropdownButtonItem>,
theme: liora_theme::Theme,
disabled: bool,
) -> AnyElement {
div()
.id(element_id(format!("{dropdown_id}-menu")))
.cursor_default()
.occlude()
.flex()
.flex_col()
.py_1()
.min_w(px(188.0))
.max_h(px(240.0))
.children(items.into_iter().enumerate().map(|(index, item)| {
dropdown_button_item(dropdown_id.clone(), index, item, theme.clone(), disabled)
}))
.into_any_element()
}
fn dropdown_button_item(
dropdown_id: SharedString,
index: usize,
item: DropdownButtonItem,
theme: liora_theme::Theme,
menu_disabled: bool,
) -> AnyElement {
let disabled = menu_disabled || item.disabled;
let item_id = element_id(format!("{dropdown_id}-item-{index}"));
let label = item.label;
let on_click = item.on_click;
let icon = item.icon;
let text_color = item_text_color(&theme, disabled, item.danger);
let hover_color = if item.danger {
theme.danger.base
} else {
theme.primary.base
};
div()
.id(item_id)
.flex()
.items_center()
.gap_2()
.min_h(px(34.0))
.px_3()
.py_2()
.text_size(px(theme.font_size.md))
.text_color(text_color)
.when(disabled, |s| s.cursor_not_allowed())
.when(!disabled, |s| {
s.cursor_pointer()
.hover(move |s| s.bg(theme.neutral.hover).text_color(hover_color))
})
.when_some(icon, |s, icon| {
s.child(Icon::new(icon).size(px(14.0)).color(text_color))
})
.child(div().text_sm().child(label))
.when(!disabled, |s| {
s.on_mouse_down(MouseButton::Left, move |_, window, cx| {
on_click(window, cx);
clear_popover(&dropdown_id, cx);
cx.stop_propagation();
})
})
.into_any_element()
}
fn item_text_color(theme: &liora_theme::Theme, disabled: bool, danger: bool) -> Hsla {
if disabled {
theme.neutral.text_disabled
} else if danger {
theme.danger.base
} else {
theme.neutral.text_1
}
}
#[cfg(test)]
mod tests {
#[test]
fn dropdown_button_flushes_shared_popover_padding_for_menu_layout() {
let source = include_str!("dropdown_button.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(source.contains(".flush_content()"));
}
use super::*;
#[test]
fn dropdown_button_builders_track_state() {
let button = DropdownButton::new("Deploy")
.id("deploy-menu")
.primary()
.large()
.secondary()
.split(true)
.icon_start(IconName::Rocket)
.on_click(|_, _| {})
.placement(Placement::TopEnd)
.close_on_click_outside(false)
.close_on_escape(false)
.item("Preview", |_, _| {})
.menu_item(DropdownButtonItem::new("Rollback", |_, _| {}).icon(IconName::Undo2))
.disabled_item("Locked")
.danger_item("Delete", |_, _| {});
assert_eq!(button.id.as_ref().map(AsRef::as_ref), Some("deploy-menu"));
assert_eq!(button.variant, ButtonVariant::Primary);
assert_eq!(button.size, ButtonSize::Large);
assert!(button.secondary);
assert!(button.split);
assert_eq!(button.leading_icon, Some(IconName::Rocket));
assert_eq!(button.placement, Placement::TopEnd);
assert!(!button.close_on_click_outside);
assert!(!button.close_on_escape);
assert_eq!(button.items.len(), 4);
assert!(button.items[1].icon.is_some());
assert!(button.items[2].disabled);
assert!(button.items[3].danger);
}
#[test]
fn dropdown_button_reuses_popover_and_exposes_split_trigger() {
let source = include_str!("dropdown_button.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(source.contains("Popover::new(trigger)"));
assert!(source.contains("fn split_trigger"));
assert!(source.contains("IconName::ChevronDown"));
assert!(source.contains("clear_popover(&dropdown_id, cx)"));
assert!(source.contains("cx.stop_propagation();"));
}
}