diff --git a/demo/src/app.rs b/demo/src/app.rs index a243770..191a826 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -62,6 +62,7 @@ fn TheRouter(is_routing: RwSignal) -> impl IntoView { + diff --git a/demo/src/pages/components.rs b/demo/src/pages/components.rs index 1cf7946..4864780 100644 --- a/demo/src/pages/components.rs +++ b/demo/src/pages/components.rs @@ -130,6 +130,10 @@ pub(crate) fn gen_menu_data() -> Vec { value: "divider".into(), label: "Divider".into(), }, + MenuItemOption { + value: "dropdown".into(), + label: "Dropdown".into(), + }, MenuItemOption { value: "icon".into(), label: "Icon".into(), diff --git a/demo_markdown/docs/dropdown/mod.md b/demo_markdown/docs/dropdown/mod.md new file mode 100644 index 0000000..c2b85de --- /dev/null +++ b/demo_markdown/docs/dropdown/mod.md @@ -0,0 +1,182 @@ +# Dropdown + +```rust demo +let message = use_message(); + +let on_select = move |key: String| { + match key.as_str() { + "facebook" => message.create( "Facebook".into(), MessageVariant::Success, Default::default(),), + "twitter" => message.create( "Twitter".into(), MessageVariant::Warning, Default::default(),), + _ => () + } +}; + + +view! { + + + + + + + + + + + + + + + + + + +} +``` + +### Placement + +```rust demo +use leptos_meta::Style; + +let on_select = move |key| println!("{}", key); + +view! { + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + + + + + + "Content" + + + +} +``` + +### Dropdown Props + +| Name | Type | Default | Description | +| ------------ | ----------------------------------- | ---------------------------- | ------------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the dropdown element. | +| on_select | `Callback` | | Called when item is selected. | +| trigger_type | `DropdownTriggerType` | `DropdownTriggerType::Click` | Action that displays the dropdown. | +| placement | `DropdownPlacement` | `DropdownPlacement::Bottom` | Dropdown placement. | +| children | `Children` | | The content inside dropdown. | + +### DropdownItem Props + +| Name | Type | Default | Description | +| -------- | -------------------------------------------- | -------------------- | ------------------------------------------------ | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the dropdown item element. | +| key | `MaybeSignal` | `Default::default()` | The key of the dropdown item. | +| label | `MaybeSignal` | `Default::default()` | The label of the dropdown item. | +| icon | `OptionalMaybeSignal` | `None` | The icon of the dropdown item. | +| disabled | `MaybeSignal` | `false` | Whether the dropdown item is disabled. | + + +### Dropdown Slots + +| Name | Default | Description | +| --------------- | ------- | ------------------------------------------------ | +| DropdownTrigger | `None` | The element or component that triggers dropdown. | + +### DropdownTriger Props + +| Name | Type | Default | Description | +| ------------ | ----------------------------------- | ---------------------------- | -------------------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the dropdown trigger element. | +| children | `Children` | | The content inside dropdown trigger. | + diff --git a/demo_markdown/docs/popover/mod.md b/demo_markdown/docs/popover/mod.md index 28b2be5..daddd35 100644 --- a/demo_markdown/docs/popover/mod.md +++ b/demo_markdown/docs/popover/mod.md @@ -146,15 +146,24 @@ view! { ### Popover Props -| Name | Type | Default | Description | -| --------- | ----------------------------------- | ----------------------- | ----------------------------- | -| class | `OptionalProp>` | `Default::default()` | Content class of the popover. | -| placement | `PopoverPlacement` | `PopoverPlacement::Top` | Popover placement. | -| tooltip | `bool` | `false` | Tooltip. | -| children | `Children` | | The content inside popover. | +| Name | Type | Default | Description | +| -------------| ----------------------------------- | -------------------------- | --------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Content class of the popover. | +| placement | `PopoverPlacement` | `PopoverPlacement::Top` | Popover placement. | +| trigger_type | `PopoverTriggerType` | `PopoverTriggerType::Hover`| Action that displays the dropdown | +| tooltip | `bool` | `false` | Tooltip. | +| children | `Children` | | The content inside popover. | ### Popover Slots | Name | Default | Description | | -------------- | ------- | ----------------------------------------------- | | PopoverTrigger | | The element or component that triggers popover. | + +### PopoverTriger Props + +| Name | Type | Default | Description | +| ------------ | ----------------------------------- | ---------------------------- | -------------------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the popover trigger element. | +| children | `Children` | | The content inside popover trigger. | + diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index e5322c7..680e4e2 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -70,7 +70,8 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "ThemeMdPage" => "../docs/theme/mod.md", "TimePickerMdPage" => "../docs/time_picker/mod.md", "TypographyMdPage" => "../docs/typography/mod.md", - "UploadMdPage" => "../docs/upload/mod.md" + "UploadMdPage" => "../docs/upload/mod.md", + "DropdownMdPage" => "../docs/dropdown/mod.md" }; let mut fn_list = vec![]; diff --git a/thaw/src/dropdown/dropdown-item.css b/thaw/src/dropdown/dropdown-item.css new file mode 100644 index 0000000..18027df --- /dev/null +++ b/thaw/src/dropdown/dropdown-item.css @@ -0,0 +1,16 @@ +.thaw-dropdown-item{ + padding: 6px 5px; + border-radius: 2px; + cursor: pointer; + display: flex; + align-items: center; +} + +.thaw-dropdown-item:hover:not(.thaw-dropdown-item--disabled) { + background-color: var(--thaw-background-color-hover); +} + +.thaw-dropdown-item.thaw-dropdown-item--disabled { + color: var(--thaw-font-color-disabled); + cursor: not-allowed; +} diff --git a/thaw/src/dropdown/dropdown.css b/thaw/src/dropdown/dropdown.css new file mode 100644 index 0000000..f0a8c5f --- /dev/null +++ b/thaw/src/dropdown/dropdown.css @@ -0,0 +1,68 @@ +.thaw-dropdown { + position: relative; + padding: 5px; + background-color: var(--thaw-background-color); + color: var(--thaw-font-color); + border-radius: 3px; + transform-origin: inherit; +} + +[data-thaw-placement="top-start"] > .thaw-dropdown, +[data-thaw-placement="top-end"] > .thaw-dropdown, +[data-thaw-placement="top"] > .thaw-dropdown { + margin-bottom: 4px; + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); +} + +[data-thaw-placement="bottom-start"] > .thaw-dropdown, +[data-thaw-placement="bottom-end"] > .thaw-dropdown, +[data-thaw-placement="bottom"] > .thaw-dropdown { + margin-top: 4px; + box-shadow: 0 -3px 6px -4px rgba(0, 0, 0, 0.12), + 0 -6px 16px 0 rgba(0, 0, 0, 0.08), 0 -9px 28px 8px rgba(0, 0, 0, 0.05); +} + +[data-thaw-placement="left-start"] > .thaw-dropdown, +[data-thaw-placement="left-end"] > .thaw-dropdown, +[data-thaw-placement="left"] > .thaw-dropdown { + margin-right: 4px; + box-shadow: 3px 0 6px -4px rgba(0, 0, 0, 0.12), + 6px 0 16px 0 rgba(0, 0, 0, 0.08), 9px 0 28px 8px rgba(0, 0, 0, 0.05); +} + +[data-thaw-placement="right-start"] > .thaw-dropdown, +[data-thaw-placement="right-end"] > .thaw-dropdown, +[data-thaw-placement="right"] > .thaw-dropdown { + margin-left: 4px; + box-shadow: -3px 0 6px -4px rgba(0, 0, 0, 0.12), + -6px 0 16px 0 rgba(0, 0, 0, 0.08), -9px 0 28px 8px rgba(0, 0, 0, 0.05); +} + +.thaw-dropdown.dropdown-transition-enter-from, +.thaw-dropdown.dropdown-transition-leave-to { + opacity: 0; + transform: scale(0.85); +} + +.thaw-dropdown.dropdown-transition-enter-to, +.thaw-dropdown.dropdown-transition-leave-from { + transform: scale(1); + opacity: 1; +} + +.thaw-dropdown.dropdown-transition-enter-active { + transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.15s cubic-bezier(0, 0, 0.2, 1), + transform 0.15s cubic-bezier(0, 0, 0.2, 1); +} + +.thaw-dropdown.dropdown-transition-leave-active { + transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.15s cubic-bezier(0.4, 0, 1, 1), + transform 0.15s cubic-bezier(0.4, 0, 1, 1); +} diff --git a/thaw/src/dropdown/dropdown_item.rs b/thaw/src/dropdown/dropdown_item.rs new file mode 100644 index 0000000..3ffff0b --- /dev/null +++ b/thaw/src/dropdown/dropdown_item.rs @@ -0,0 +1,75 @@ +use leptos::*; +use thaw_components::{Fallback, If, OptionComp, Then}; +use thaw_utils::{class_list, mount_style, OptionalMaybeSignal, OptionalProp}; + +use crate::{ + dropdown::{HasIcon, OnSelect}, + use_theme, Icon, Theme, +}; + +#[component] +pub fn DropdownItem( + #[prop(optional, into)] icon: OptionalMaybeSignal, + #[prop(into)] label: MaybeSignal, + #[prop(into)] key: MaybeSignal, + #[prop(optional, into)] disabled: MaybeSignal, + #[prop(optional, into)] class: OptionalProp>, +) -> impl IntoView { + mount_style("dropdown-item", include_str!("./dropdown-item.css")); + let theme = use_theme(Theme::light); + let css_vars = create_memo(move |_| { + let mut css_vars = String::new(); + theme.with(|theme| { + css_vars.push_str(&format!( + "--thaw-background-color-hover: {};", + theme.dropdown.item_color_hover + )); + css_vars.push_str(&format!( + "--thaw-font-color-disabled: {};", + theme.dropdown.font_color_disabled + )); + }); + css_vars + }); + + let has_icon = use_context::().expect("HasIcon not provided").0; + + if icon.get().is_some() { + has_icon.set(true); + } + + let on_select = use_context::().expect("OnSelect not provided").0; + + let on_click = move |_| { + if disabled.get() { + return; + } + on_select.call(key.get()); + }; + + view! { +
+ + + + + + + + + + + + + {label} +
+ } +} diff --git a/thaw/src/dropdown/mod.rs b/thaw/src/dropdown/mod.rs new file mode 100644 index 0000000..14af3bc --- /dev/null +++ b/thaw/src/dropdown/mod.rs @@ -0,0 +1,193 @@ +mod dropdown_item; +mod theme; + +pub use dropdown_item::*; + +use std::time::Duration; + +use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement}; +pub use theme::DropdownTheme; + +use leptos::{leptos_dom::helpers::TimeoutHandle, *}; +use thaw_utils::{ + add_event_listener, call_on_click_outside, class_list, mount_style, OptionalProp, +}; + +use crate::{use_theme, Theme}; + +#[slot] +pub struct DropdownTrigger { + #[prop(optional, into)] + class: OptionalProp>, + children: Children, +} + +#[derive(Copy, Clone)] +struct HasIcon(RwSignal); + +#[derive(Copy, Clone)] +struct OnSelect(Callback); + +#[component] +pub fn Dropdown( + #[prop(optional, into)] class: OptionalProp>, + dropdown_trigger: DropdownTrigger, + #[prop(optional)] trigger_type: DropdownTriggerType, + #[prop(optional)] placement: DropdownPlacement, + #[prop(into)] on_select: Callback, + children: Children, +) -> impl IntoView { + mount_style("dropdown", include_str!("./dropdown.css")); + let theme = use_theme(Theme::light); + let css_vars = create_memo(move |_| { + let mut css_vars = String::new(); + theme.with(|theme| { + css_vars.push_str(&format!( + "--thaw-background-color: {};", + theme.dropdown.background_color + )); + css_vars.push_str(&format!("--thaw-font-color: {};", theme.common.font_color)); + }); + css_vars + }); + let dropdown_ref = create_node_ref::(); + let target_ref = create_node_ref::(); + let is_show_dropdown = create_rw_signal(false); + let show_dropdown_handle = store_value(None::); + + let on_mouse_enter = move |_| { + if trigger_type != DropdownTriggerType::Hover { + return; + } + show_dropdown_handle.update_value(|handle| { + if let Some(handle) = handle.take() { + handle.clear(); + } + }); + is_show_dropdown.set(true); + }; + let on_mouse_leave = move |_| { + if trigger_type != DropdownTriggerType::Hover { + return; + } + show_dropdown_handle.update_value(|handle| { + if let Some(handle) = handle.take() { + handle.clear(); + } + *handle = set_timeout_with_handle( + move || { + is_show_dropdown.set(false); + }, + Duration::from_millis(100), + ) + .ok(); + }); + }; + + if trigger_type != DropdownTriggerType::Hover { + call_on_click_outside( + dropdown_ref, + Callback::new(move |_| is_show_dropdown.set(false)), + ); + } + + target_ref.on_load(move |target_el| { + add_event_listener(target_el.into_any(), ev::click, move |event| { + if trigger_type != DropdownTriggerType::Click { + return; + } + event.stop_propagation(); + is_show_dropdown.update(|show| *show = !*show); + }); + }); + let DropdownTrigger { + class: trigger_class, + children: trigger_children, + } = dropdown_trigger; + + provide_context(HasIcon(create_rw_signal(false))); + provide_context(OnSelect(Callback::::new(move |key| { + is_show_dropdown.set(false); + on_select.call(key); + }))); + + view! { + +
+ {trigger_children()} +
+ + +
+
{children()}
+
+
+
+
+ } +} + +#[derive(Default, PartialEq, Clone)] +pub enum DropdownTriggerType { + Hover, + #[default] + Click, +} + +impl Copy for DropdownTriggerType {} + +#[derive(Default)] +pub enum DropdownPlacement { + Top, + #[default] + Bottom, + Left, + Right, + TopStart, + TopEnd, + LeftStart, + LeftEnd, + RightStart, + RightEnd, + BottomStart, + BottomEnd, +} + +impl From for FollowerPlacement { + fn from(value: DropdownPlacement) -> Self { + match value { + DropdownPlacement::Top => Self::Top, + DropdownPlacement::Bottom => Self::Bottom, + DropdownPlacement::Left => Self::Left, + DropdownPlacement::Right => Self::Right, + DropdownPlacement::TopStart => Self::TopStart, + DropdownPlacement::TopEnd => Self::TopEnd, + DropdownPlacement::LeftStart => Self::LeftStart, + DropdownPlacement::LeftEnd => Self::LeftEnd, + DropdownPlacement::RightStart => Self::RightStart, + DropdownPlacement::RightEnd => Self::RightEnd, + DropdownPlacement::BottomStart => Self::BottomStart, + DropdownPlacement::BottomEnd => Self::BottomEnd, + } + } +} diff --git a/thaw/src/dropdown/theme.rs b/thaw/src/dropdown/theme.rs new file mode 100644 index 0000000..456c458 --- /dev/null +++ b/thaw/src/dropdown/theme.rs @@ -0,0 +1,26 @@ +use crate::theme::ThemeMethod; + +#[derive(Clone)] +pub struct DropdownTheme { + pub background_color: String, + pub item_color_hover: String, + pub font_color_disabled: String, +} + +impl ThemeMethod for DropdownTheme { + fn light() -> Self { + Self { + background_color: "#fff".into(), + item_color_hover: "#f3f5f6".into(), + font_color_disabled: "#c2c2c2".into(), + } + } + + fn dark() -> Self { + Self { + background_color: "#48484e".into(), + item_color_hover: "#ffffff17".into(), + font_color_disabled: "#ffffff61".into(), + } + } +} diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 396ce71..802e7ba 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -15,6 +15,7 @@ mod color_picker; mod date_picker; mod divider; mod drawer; +mod dropdown; mod global_style; mod grid; mod icon; @@ -62,6 +63,7 @@ pub use color_picker::*; pub use date_picker::*; pub use divider::*; pub use drawer::*; +pub use dropdown::*; pub use global_style::*; pub use grid::*; pub use icon::*; diff --git a/thaw/src/popover/mod.rs b/thaw/src/popover/mod.rs index 992b8ff..1778501 100644 --- a/thaw/src/popover/mod.rs +++ b/thaw/src/popover/mod.rs @@ -6,7 +6,9 @@ use crate::{use_theme, Theme}; use leptos::{leptos_dom::helpers::TimeoutHandle, *}; use std::time::Duration; use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement}; -use thaw_utils::{add_event_listener, class_list, mount_style, OptionalProp}; +use thaw_utils::{ + add_event_listener, call_on_click_outside, class_list, mount_style, OptionalProp, +}; #[slot] pub struct PopoverTrigger { @@ -77,34 +79,13 @@ pub fn Popover( .ok(); }); }; - #[cfg(any(feature = "csr", feature = "hydrate"))] - { - let handle = window_event_listener(ev::click, move |ev| { - use leptos::wasm_bindgen::__rt::IntoJsResult; - if trigger_type != PopoverTriggerType::Click { - return; - } - let el = ev.target(); - let mut el: Option = - el.into_js_result().map_or(None, |el| Some(el.into())); - let body = document().body().unwrap(); - while let Some(current_el) = el { - if current_el == *body { - break; - }; - let Some(popover_el) = popover_ref.get_untracked() else { - break; - }; - if current_el == ***popover_el { - return; - } - el = current_el.parent_element(); - } - is_show_popover.set(false); - }); - on_cleanup(move || handle.remove()); - } + if trigger_type != PopoverTriggerType::Hover { + call_on_click_outside( + popover_ref, + Callback::new(move |_| is_show_popover.set(false)), + ); + } target_ref.on_load(move |target_el| { add_event_listener(target_el.into_any(), ev::click, move |event| { if trigger_type != PopoverTriggerType::Click { diff --git a/thaw/src/theme/mod.rs b/thaw/src/theme/mod.rs index 2f20a9a..cbea65e 100644 --- a/thaw/src/theme/mod.rs +++ b/thaw/src/theme/mod.rs @@ -4,8 +4,8 @@ use self::common::CommonTheme; use crate::{ mobile::{NavBarTheme, TabbarTheme}, AlertTheme, AnchorTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme, - ButtonTheme, CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, - MenuTheme, MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, + ButtonTheme, CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, DropdownTheme, + InputTheme, MenuTheme, MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme, SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme, UploadTheme, }; @@ -45,6 +45,7 @@ pub struct Theme { pub time_picker: TimePickerTheme, pub date_picker: DatePickerTheme, pub popover: PopoverTheme, + pub dropdown: DropdownTheme, pub collapse: CollapseTheme, pub scrollbar: ScrollbarTheme, pub back_top: BackTopTheme, @@ -81,6 +82,7 @@ impl Theme { time_picker: TimePickerTheme::light(), date_picker: DatePickerTheme::light(), popover: PopoverTheme::light(), + dropdown: DropdownTheme::light(), collapse: CollapseTheme::light(), scrollbar: ScrollbarTheme::light(), back_top: BackTopTheme::light(), @@ -116,6 +118,7 @@ impl Theme { time_picker: TimePickerTheme::dark(), date_picker: DatePickerTheme::dark(), popover: PopoverTheme::dark(), + dropdown: DropdownTheme::dark(), collapse: CollapseTheme::dark(), scrollbar: ScrollbarTheme::dark(), back_top: BackTopTheme::dark(), diff --git a/thaw_utils/src/lib.rs b/thaw_utils/src/lib.rs index 2b6d071..d53465d 100644 --- a/thaw_utils/src/lib.rs +++ b/thaw_utils/src/lib.rs @@ -2,6 +2,7 @@ pub mod class_list; mod dom; mod event_listener; mod hooks; +mod on_click_outside; mod optional_prop; mod signals; mod throttle; @@ -12,6 +13,7 @@ pub use event_listener::{ add_event_listener, add_event_listener_with_bool, EventListenerHandle, IntoEventTarget, }; pub use hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame}; +pub use on_click_outside::call_on_click_outside; pub use optional_prop::OptionalProp; pub use signals::{ create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal, diff --git a/thaw_utils/src/on_click_outside.rs b/thaw_utils/src/on_click_outside.rs new file mode 100644 index 0000000..b887985 --- /dev/null +++ b/thaw_utils/src/on_click_outside.rs @@ -0,0 +1,28 @@ +use leptos::{html::Div, *}; + +pub fn call_on_click_outside(element: NodeRef
, on_click: Callback<()>) { + #[cfg(any(feature = "csr", feature = "hydrate"))] + { + let handle = window_event_listener(ev::click, move |ev| { + use leptos::wasm_bindgen::__rt::IntoJsResult; + let el = ev.target(); + let mut el: Option = + el.into_js_result().map_or(None, |el| Some(el.into())); + let body = document().body().unwrap(); + while let Some(current_el) = el { + if current_el == *body { + break; + }; + let Some(displayed_el) = element.get_untracked() else { + break; + }; + if current_el == ***displayed_el { + return; + } + el = current_el.parent_element(); + } + on_click.call(()); + }); + on_cleanup(move || handle.remove()); + } +}