diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg new file mode 100644 index 00000000..1d316c76 --- /dev/null +++ b/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/crates/ui-story/src/dropdown_story.rs b/crates/ui-story/src/dropdown_story.rs index 49eb08e4..dec0274f 100644 --- a/crates/ui-story/src/dropdown_story.rs +++ b/crates/ui-story/src/dropdown_story.rs @@ -1,13 +1,13 @@ use std::{rc::Rc, vec}; use gpui::{ - div, ClickEvent, IntoElement, ParentElement, Render, RenderOnce, SharedString, Styled, - ViewContext, WindowContext, + div, px, ClickEvent, IntoElement, ParentElement, Render, RenderOnce, SharedString, Styled, + View, ViewContext, VisualContext, WindowContext, }; use ui::{ checkbox::Checkbox, - dropdown::{Dropdown, DropdownItem}, + dropdown::{Dropdown, DropdownDelegate, DropdownItem}, h_flex, v_flex, Disableable as _, Selection, }; @@ -25,25 +25,54 @@ impl Country { } impl DropdownItem for Country { - fn title(&self) -> SharedString { - self.name.into() + fn title(&self) -> &str { + self.name } - fn value(&self) -> SharedString { - self.code.into() + fn value(&self) -> &str { + self.code } } -#[derive(IntoElement)] +struct CounterDelegate(Vec); +struct FuritDelegate(Vec); + pub struct DropdownStory { - dropdown1: Dropdown, - dropdown2: Dropdown, - countries: Vec, + country_dropdown: View>, + furit_dropdown: View>, +} + +impl DropdownDelegate for CounterDelegate { + fn len(&self) -> usize { + self.0.len() + } + + fn get(&self, ix: usize) -> Option<&dyn DropdownItem> { + if let Some(item) = self.0.get(ix) { + Some(item) + } else { + None + } + } +} + +impl DropdownDelegate for FuritDelegate { + fn len(&self) -> usize { + self.0.len() + } + + fn get(&self, ix: usize) -> Option<&dyn DropdownItem> { + if let Some(item) = self.0.get(ix) { + Some(item) + } else { + None + } + } } impl DropdownStory { - pub(crate) fn new(cx: &mut WindowContext) -> Self { - let countries = vec![ + pub(crate) fn new(cx: &mut ViewContext) -> Self { + let countries = CounterDelegate(vec![ Country::new("United States", "US"), Country::new("Canada", "CA"), Country::new("Mexico", "MX"), @@ -55,22 +84,29 @@ impl DropdownStory { Country::new("Colombia", "CO"), Country::new("Venezuela", "VE"), Country::new("Ecuador", "EC"), - ]; + ]); - let items2 = vec![ - "Apple", - "Orange", - "Banana", - "Grape", - "Pineapple", - "Watermelon", - "Avocado", - ]; + let country_dropdown = cx.new_view(|cx| Dropdown::new("dropdown-country", countries, cx)); + + let furits = FuritDelegate( + [ + "Apple", + "Orange", + "Banana", + "Grape", + "Pineapple", + "Watermelon", + "Avocado", + ] + .iter() + .map(|s| s.to_string()) + .collect(), + ); + let furit_dropdown = cx.new_view(|cx| Dropdown::new("dropdown-furits", furits, cx)); Self { - countries, - dropdown1: Dropdown::new("dropdown-country", Rc::new(countries), cx), - dropdown2: Dropdown::new("dropdown-fruit", Rc::new(items2), cx), + country_dropdown, + furit_dropdown, } } @@ -80,73 +116,34 @@ impl DropdownStory { } } -impl RenderOnce for CheckboxStory { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { +impl Render for DropdownStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { story_case( - "Checkbox", - "A control that allows the user to toggle between checked and not checked.", + "Dropdown", + "Displays a list of options for the user to pick from—triggered by a button.", ) .child( - v_flex().items_start().justify_start().gap_4().child( - h_flex() - .items_center() - .gap_4() - .child(self.check1) - .child(self.check1_1) - .child( - Checkbox::new("check1_2", cx) - .checked(Selection::Selected) - .on_click(Self::on_click), - ), - ), - ) - .child( - h_flex() - .items_center() + v_flex() + .size_full() + .items_start() + .justify_start() .gap_4() .child( - Checkbox::new("check2", cx) - .checked(Selection::Unselected) - .label("With label (Unchecked)") - .on_click(Self::on_click), + h_flex() + .w_full() + .max_w(px(640.)) + .items_center() + .gap_4() + .child(self.country_dropdown.clone()) + .child(self.furit_dropdown.clone()), ) .child( - Checkbox::new("check2_1", cx) - .label("With Label (Indeterminate)") - .checked(Selection::Indeterminate) - .on_click(Self::on_click), - ) - .child( - Checkbox::new("check2_2", cx) - .label("With Label (Checked)") - .checked(Selection::Selected) - .on_click(Self::on_click), + h_flex() + .w_full() + .items_center() + .gap_4() + .child("This is other text."), ), ) - .child( - h_flex().items_center().gap_4().child( - h_flex() - .items_center() - .gap_4() - .child( - Checkbox::new("check3", cx) - .label("Disabled Checked") - .checked(Selection::Selected) - .disabled(true), - ) - .child( - Checkbox::new("check3_1", cx) - .label("Disabled Unchecked") - .checked(Selection::Unselected) - .disabled(true), - ) - .child( - Checkbox::new("check3_2", cx) - .label("Disabled Indeterminate") - .checked(Selection::Indeterminate) - .disabled(true), - ), - ), - ) } } diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index 55568aab..2f30f3b2 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -9,7 +9,7 @@ use gpui::{ mod button_story; mod checkbox_story; -// mod dropdown_story; +mod dropdown_story; mod input_story; mod picker_story; mod switch_story; @@ -21,6 +21,7 @@ use ui::{ }; use button_story::ButtonStory; +use dropdown_story::DropdownStory; use input_story::InputStory; use picker_story::PickerStory; use switch_story::SwitchStory; @@ -77,6 +78,7 @@ enum StoryType { Checkbox, Switch, Picker, + Dropdown, } impl Display for StoryType { @@ -87,6 +89,7 @@ impl Display for StoryType { Self::Checkbox => write!(f, "Checkbox"), Self::Switch => write!(f, "Switch"), Self::Picker => write!(f, "Picker"), + Self::Dropdown => write!(f, "Dropdown"), } } } @@ -98,6 +101,7 @@ pub struct Stories { input_story: View, switch_story: View, picker_story: View, + dropdown_story: View, } impl Stories { @@ -108,6 +112,7 @@ impl Stories { input_story: cx.new_view(|cx| InputStory::new(cx)), switch_story: cx.new_view(|cx| SwitchStory::new(cx)), picker_story: cx.new_view(|cx| PickerStory::new(cx)), + dropdown_story: cx.new_view(|cx| DropdownStory::new(cx)), } } @@ -120,27 +125,23 @@ impl Stories { cx.notify(); } - fn render_story_buttons(&self, cx: &mut ViewContext) -> impl IntoElement { + fn tabs(&self, cx: &mut ViewContext) -> impl IntoElement { div() .flex() .items_center() .gap_4() .w_full() .child(TabBar::new("story-tabs").children(vec![ - self.swith_button("story-button", StoryType::Button, cx), - self.swith_button("story-input", StoryType::Input, cx), - self.swith_button("story-checkbox", StoryType::Checkbox, cx), - self.swith_button("story-switch", StoryType::Switch, cx), - self.swith_button("story-picker", StoryType::Picker, cx), + self.tab("story-button", StoryType::Button, cx), + self.tab("story-input", StoryType::Input, cx), + self.tab("story-checkbox", StoryType::Checkbox, cx), + self.tab("story-switch", StoryType::Switch, cx), + self.tab("story-picker", StoryType::Picker, cx), + self.tab("story-dropdown", StoryType::Dropdown, cx), ])) } - fn swith_button( - &self, - id: &str, - ty: StoryType, - cx: &mut ViewContext, - ) -> impl IntoElement { + fn tab(&self, id: &str, ty: StoryType, cx: &mut ViewContext) -> impl IntoElement { let name = format!("{}", ty); let is_active = ty == self.active; @@ -159,13 +160,14 @@ impl Render for Stories { .flex() .flex_col() .gap_4() - .child(self.render_story_buttons(cx)) + .child(self.tabs(cx)) .map(|this| match self.active { StoryType::Button => this.child(self.button_story.clone()), StoryType::Input => this.child(self.input_story.clone()), StoryType::Checkbox => this.child(CheckboxStory::new(cx).into_any_element()), StoryType::Switch => this.child(self.switch_story.clone()), StoryType::Picker => this.child(self.picker_story.clone()), + StoryType::Dropdown => this.child(self.dropdown_story.clone()), }) } } diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 6a6c7381..1293b939 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -1,15 +1,18 @@ use std::rc::Rc; use gpui::{ - div, prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, - ParentElement as _, Render, RenderOnce, SharedString, Stateful, Styled as _, View, ViewContext, - VisualContext as _, WeakView, + div, prelude::FluentBuilder as _, px, AppContext, Div, ElementId, FocusHandle, FocusableView, + InteractiveElement, IntoElement, ParentElement as _, Render, RenderOnce, SharedString, + Stateful, StatefulInteractiveElement as _, Styled as _, View, ViewContext, VisualContext as _, + WeakView, }; use crate::{ + h_flex, list::ListItem, picker::{Picker, PickerDelegate}, - IconName, + theme::ActiveTheme, + v_flex, IconName, StyledExt, }; /// A trait for items that can be displayed in a dropdown. @@ -18,19 +21,34 @@ pub trait DropdownItem { fn value(&self) -> &str; } -pub trait DropdownDelegate { - type Item: DropdownItem; +impl DropdownItem for String { + fn title(&self) -> &str { + self + } - fn len(&self) -> usize; - fn get(&self, index: usize) -> Option<&Self::Item>; + fn value(&self) -> &str { + self + } } -struct DropdownPickerDelegate { +pub trait DropdownDelegate { + fn len(&self) -> usize; + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn get(&self, ix: usize) -> Option<&dyn DropdownItem>; +} + +struct DropdownPickerDelegate { + delegate: D, dropdown: WeakView>, selected_index: usize, } -impl PickerDelegate for DropdownPickerDelegate { +impl PickerDelegate for DropdownPickerDelegate +where + D: DropdownDelegate + 'static, +{ type ListItem = ListItem; fn match_count(&self) -> usize { @@ -57,7 +75,7 @@ impl PickerDelegate for DropdownPickerDelegate { .selected(selected) .py_1() .px_3() - .child(item.title()); + .child(item.title().to_string()); Some(list_item) } else { None @@ -68,7 +86,6 @@ impl PickerDelegate for DropdownPickerDelegate { if let Some(view) = self.dropdown.upgrade() { cx.update_view(&view, |view, cx| { view.open = false; - cx.notify(); }); } } @@ -77,47 +94,117 @@ impl PickerDelegate for DropdownPickerDelegate { if let Some(view) = self.dropdown.upgrade() { cx.update_view(&view, |view, cx| { if let Some(item) = self.delegate.get(self.selected_index) { - view.value = Some(item.value()); + view.title = Some(item.title().to_string().into()); + view.value = Some(item.value().to_string().into()); } view.open = false; - cx.notify(); }); } } } -pub struct Dropdown { - base: Stateful
, - delegate: D, - picker: View>, +pub struct Dropdown { + id: ElementId, + focus_handle: FocusHandle, + picker: View>>, open: bool, /// The value of the selected item. value: Option, + title: Option, } -impl Dropdown { +impl Dropdown +where + D: DropdownDelegate + 'static, +{ pub fn new(id: impl Into, delegate: D, cx: &mut ViewContext) -> Self { - let delegate = DropdownPickerDelegate { - // delegate, + let picker_delegate = DropdownPickerDelegate { + delegate, dropdown: cx.view().downgrade(), selected_index: 0, }; - let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx)); + let picker = cx.new_view(|cx| Picker::uniform_list(picker_delegate, cx)); Self { - delegate, - base: div().id(id.into()), + id: id.into(), + focus_handle: cx.focus_handle(), picker, open: false, + title: None, value: None, } } -} -impl RenderOnce for Dropdown { - fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement { - self.base - .child(self.value.unwrap_or_else(|| "Select...".into())) - .when(self.open, |this| this.child(self.picker)) + pub fn set_value(&mut self, value: impl Into, cx: &mut ViewContext) { + self.value = Some(value.into()); + cx.notify(); + } +} + +impl FocusableView for Dropdown +where + D: DropdownDelegate + 'static, +{ + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Dropdown +where + D: DropdownDelegate + 'static, +{ + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let title = self.title.clone().unwrap_or_else(|| "Select...".into()); + let is_focused = self.focus_handle.is_focused(cx); + + div() + .key_context("Dropdown") + .size_full() + .relative() + .child( + div() + .id(self.id.clone()) + .relative() + .flex() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().input) + .rounded_sm() + .shadow_sm() + .px_3() + .py_2() + .when(is_focused, |this| this.border_color(cx.theme().ring)) + .on_click(cx.listener(|this, _, cx| { + this.open = !this.open; + cx.notify(); + })) + .child( + v_flex() + .items_center() + .justify_between() + .child(title) + .child(IconName::ChevronDown), + ), + ) + .when(self.open, |this| { + this.child( + div() + .absolute() + // Top is the dropdown input height + border + .top(px(50.)) + .left_0() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().input) + .rounded_sm() + .shadow_md() + .track_focus(&self.picker.focus_handle(cx)) + .child(self.picker.clone()), + ) + }) } } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index b178dcc4..057031d2 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -8,6 +8,7 @@ pub enum IconName { Maximize, Minimize, Close, + ChevronDown, } impl IconName { @@ -19,6 +20,7 @@ impl IconName { IconName::Maximize => "icons/maximize.svg", IconName::Minimize => "icons/minimize.svg", IconName::Close => "icons/close.svg", + IconName::ChevronDown => "icons/chevron-down.svg", } .into() } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 5be2f43d..eebe6bf4 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -16,7 +16,7 @@ pub mod theme; pub mod title_bar; pub use styled_ext::StyledExt; pub mod divider; -// pub mod dropdown; +pub mod dropdown; pub mod input; pub mod list; pub mod picker; diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs index 0dd7bf2b..69ba5321 100644 --- a/crates/ui/src/picker.rs +++ b/crates/ui/src/picker.rs @@ -204,7 +204,6 @@ impl Picker { pub fn uniform_list(delegate: D, cx: &mut ViewContext) -> Self { let query_input = Self::new_query_input("Search...", cx); - Self::new(delegate, ContainerKind::UniformList, Some(query_input), cx) }