diff --git a/demo/src/app.rs b/demo/src/app.rs index 70846f6..f0c6445 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -78,6 +78,7 @@ fn TheRouter(is_routing: RwSignal) -> impl IntoView { + diff --git a/demo/src/components/site_header.rs b/demo/src/components/site_header.rs index ff75af7..1293314 100644 --- a/demo/src/components/site_header.rs +++ b/demo/src/components/site_header.rs @@ -1,5 +1,8 @@ use super::switch_version::SwitchVersion; -use leptos::{ev, prelude::*}; +use leptos::{ + ev::{self, MouseEvent}, + prelude::*, +}; use leptos_meta::Style; use leptos_router::hooks::use_navigate; // use leptos_use::{storage::use_local_storage, utils::FromToStringCodec}; @@ -8,6 +11,7 @@ use thaw::*; #[component] pub fn SiteHeader() -> impl IntoView { let navigate = use_navigate(); + let navigate_signal = RwSignal::new(use_navigate()); let theme = Theme::use_rw_theme(); let theme_name = Memo::new(move |_| { theme.with(|theme| { @@ -109,9 +113,6 @@ pub fn SiteHeader() -> impl IntoView { .demo-header__menu-mobile { display: none !important; } - .demo-header__menu-popover-mobile { - padding: 0; - } .demo-header__right-btn .thaw-select { width: 60px; } @@ -140,7 +141,7 @@ pub fn SiteHeader() -> impl IntoView {
"Thaw UI"
- + impl IntoView { /> - change_theme(MouseEvent::new("click").unwrap()), + "Light" => change_theme(MouseEvent::new("click").unwrap()), + "github" => { _ = window().open_with_url("http://github.com/thaw-ui/thaw"); },//FIXME: breaks page + "discord" => { _ = window().open_with_url("https://discord.gg/YPxuprzu6M"); },//FIXME: breaks page + _ => navigate_signal.get()(&value, Default::default()) + + } > - - + + + + + + + + + + + + + + +} +``` + +### 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" + + + +} +``` + +### Menu Props + +| Name | Type | Default | Description | +| ------------ | ----------------------------------- | ---------------------------- | ------------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the menu element. | +| on_select | `Callback` | | Called when item is selected. | +| trigger_type | `MenuTriggerType` | `MenuTriggerType::Click` | Action that displays the menu. | +| placement | `MenuPlacement` | `MenuPlacement::Bottom` | Menu placement. | +| children | `Children` | | The content inside menu. | + +### MenuItem Props + +| Name | Type | Default | Description | +| -------- | -------------------------------------------- | -------------------- | ------------------------------------------------ | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the menu item element. | +| key | `MaybeSignal` | `Default::default()` | The key of the menu item. | +| label | `MaybeSignal` | `Default::default()` | The label of the menu item. | +| icon | `OptionalMaybeSignal` | `None` | The icon of the menu item. | +| disabled | `MaybeSignal` | `false` | Whether the menu item is disabled. | + + +### Menu Slots + +| Name | Default | Description | +| --------------- | ------- | ------------------------------------------------ | +| MenuTrigger | `None` | The element or component that triggers menu. | + +### MenuTriger Props + +| Name | Type | Default | Description | +| ------------ | ----------------------------------- | ---------------------------- | -------------------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the menu trigger element. | +| children | `Children` | | The content inside menu trigger. | + diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index 5940e5c..fd26645 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -53,6 +53,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "InputMdPage" => "../docs/input/mod.md", "LayoutMdPage" => "../docs/layout/mod.md", "LoadingBarMdPage" => "../docs/loading_bar/mod.md", + "MenuMdPage" => "../docs/menu/mod.md", "MessageBarMdPage" => "../docs/message_bar/mod.md", "PopoverMdPage" => "../docs/popover/mod.md", "ProgressBarMdPage" => "../docs/progress_bar/mod.md", diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 60a1942..be74cd4 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -24,6 +24,7 @@ mod image; mod input; mod layout; mod loading_bar; +mod menu; mod message_bar; mod nav; mod popover; @@ -71,6 +72,7 @@ pub use image::*; pub use input::*; pub use layout::*; pub use loading_bar::*; +pub use menu::*; pub use message_bar::*; pub use nav::*; pub use popover::*; diff --git a/thaw/src/menu/menu-item.css b/thaw/src/menu/menu-item.css new file mode 100644 index 0000000..de00c3e --- /dev/null +++ b/thaw/src/menu/menu-item.css @@ -0,0 +1,16 @@ +.thaw-menu-item{ + padding: 6px 5px; + border-radius: 2px; + cursor: pointer; + display: flex; + align-items: center; +} + +.thaw-menu-item:hover:not(.thaw-dropdown-item--disabled) { + background-color: var(--colorNeutralBackground1Hover); +} + +.thaw-menu-item.thaw-dropdown-item--disabled { + color: var(--colorNeutralForegroundDisabled); + cursor: not-allowed; +} diff --git a/thaw/src/menu/menu.css b/thaw/src/menu/menu.css new file mode 100644 index 0000000..90de3a4 --- /dev/null +++ b/thaw/src/menu/menu.css @@ -0,0 +1,68 @@ +.thaw-menu { + padding: 4px; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + border-radius: var(--borderRadiusMedium); + transform-origin: inherit; + position: relative; +} + +[data-thaw-placement="top-start"] > .thaw-menu, +[data-thaw-placement="top-end"] > .thaw-menu, +[data-thaw-placement="top"] > .thaw-menu { + 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-menu, +[data-thaw-placement="bottom-end"] > .thaw-menu, +[data-thaw-placement="bottom"] > .thaw-menu { + 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-menu, +[data-thaw-placement="left-end"] > .thaw-menu, +[data-thaw-placement="left"] > .thaw-menu { + 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-menu, +[data-thaw-placement="right-end"] > .thaw-menu, +[data-thaw-placement="right"] > .thaw-menu { + 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-menu.dropdown-transition-enter-from, +.thaw-menu.dropdown-transition-leave-to { + opacity: 0; + transform: scale(0.85); +} + +.thaw-menu.dropdown-transition-enter-to, +.thaw-menu.dropdown-transition-leave-from { + transform: scale(1); + opacity: 1; +} + +.thaw-menu.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-menu.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/menu/menu_item.rs b/thaw/src/menu/menu_item.rs new file mode 100644 index 0000000..cc54106 --- /dev/null +++ b/thaw/src/menu/menu_item.rs @@ -0,0 +1,57 @@ +use crate::{ + menu::{HasIcon, OnSelect}, + Icon, +}; +use leptos::prelude::*; +use thaw_components::{Fallback, If, OptionComp, Then}; +use thaw_utils::{class_list, mount_style, OptionalMaybeSignal, OptionalProp}; + +#[component] +pub fn MenuItem( + #[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("menu-item", include_str!("./menu-item.css")); + + 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(key.get()); + }; + + view! { +
+ + + + + + + + + + + + + {label} +
+ } +} diff --git a/thaw/src/menu/mod.rs b/thaw/src/menu/mod.rs new file mode 100644 index 0000000..776b4ee --- /dev/null +++ b/thaw/src/menu/mod.rs @@ -0,0 +1,199 @@ +mod menu_item; + +pub use menu_item::*; + +use crate::ConfigInjection; +use leptos::{ev, html::Div, leptos_dom::helpers::TimeoutHandle, prelude::*}; +use std::time::Duration; +use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement}; +use thaw_utils::{ + add_event_listener, call_on_click_outside, class_list, mount_style, ArcOneCallback, + OptionalProp, +}; + +#[slot] +pub struct MenuTrigger { + #[prop(optional, into)] + class: OptionalProp>, + children: Children, +} + +#[derive(Copy, Clone)] +struct HasIcon(RwSignal); + +#[derive(Clone)] +struct OnSelect(ArcOneCallback); + +#[component] +pub fn Menu( + #[prop(optional, into)] class: MaybeProp, + menu_trigger: MenuTrigger, + #[prop(optional)] trigger_type: MenuTriggerType, + #[prop(optional)] placement: MenuPlacement, + #[prop(into)] on_select: ArcOneCallback, + #[prop(optional, into)] appearance: Option>, + children: Children, +) -> impl IntoView { + mount_style("menu", include_str!("./menu.css")); + let config_provider = ConfigInjection::use_(); + + let menu_ref = NodeRef::
::new(); + let target_ref = NodeRef::
::new(); + let is_show_menu = RwSignal::new(false); + let show_menu_handle = StoredValue::new(None::); + + let on_mouse_enter = move |_| { + if trigger_type != MenuTriggerType::Hover { + return; + } + show_menu_handle.update_value(|handle| { + if let Some(handle) = handle.take() { + handle.clear(); + } + }); + is_show_menu.set(true); + }; + let on_mouse_leave = move |_| { + if trigger_type != MenuTriggerType::Hover { + return; + } + show_menu_handle.update_value(|handle| { + if let Some(handle) = handle.take() { + handle.clear(); + } + *handle = set_timeout_with_handle( + move || { + is_show_menu.set(false); + }, + Duration::from_millis(100), + ) + .ok(); + }); + }; + + if trigger_type != MenuTriggerType::Hover { + call_on_click_outside(menu_ref, Callback::new(move |_| is_show_menu.set(false))); + } + + Effect::new(move |_| { + let Some(target_el) = target_ref.get() else { + return; + }; + let handler = add_event_listener(target_el.into(), ev::click, move |event| { + if trigger_type != MenuTriggerType::Click { + return; + } + event.stop_propagation(); + is_show_menu.update(|show| *show = !*show); + }); + on_cleanup(move || handler.remove()); + }); + + let MenuTrigger { + class: trigger_class, + children: trigger_children, + } = menu_trigger; + + provide_context(HasIcon(RwSignal::new(false))); + provide_context(OnSelect(ArcOneCallback::::new(move |key| { + is_show_menu.set(false); + on_select(key); + }))); + + view! { + +
+ {trigger_children()} +
+ + +
+ {children()} +
+
+
+
+ } +} + +#[derive(Default, PartialEq, Clone)] +pub enum MenuTriggerType { + Hover, + #[default] + Click, +} + +impl Copy for MenuTriggerType {} + +#[derive(Clone)] +pub enum MenuAppearance { + Brand, + Inverted, +} + +impl MenuAppearance { + pub fn as_str(&self) -> &'static str { + match self { + MenuAppearance::Brand => "brand", + MenuAppearance::Inverted => "inverted", + } + } +} + +#[derive(Default)] +pub enum MenuPlacement { + Top, + #[default] + Bottom, + Left, + Right, + TopStart, + TopEnd, + LeftStart, + LeftEnd, + RightStart, + RightEnd, + BottomStart, + BottomEnd, +} + +impl From for FollowerPlacement { + fn from(value: MenuPlacement) -> Self { + match value { + MenuPlacement::Top => Self::Top, + MenuPlacement::Bottom => Self::Bottom, + MenuPlacement::Left => Self::Left, + MenuPlacement::Right => Self::Right, + MenuPlacement::TopStart => Self::TopStart, + MenuPlacement::TopEnd => Self::TopEnd, + MenuPlacement::LeftStart => Self::LeftStart, + MenuPlacement::LeftEnd => Self::LeftEnd, + MenuPlacement::RightStart => Self::RightStart, + MenuPlacement::RightEnd => Self::RightEnd, + MenuPlacement::BottomStart => Self::BottomStart, + MenuPlacement::BottomEnd => Self::BottomEnd, + } + } +} diff --git a/thaw/src/popover/mod.rs b/thaw/src/popover/mod.rs index 55a6555..9573a41 100644 --- a/thaw/src/popover/mod.rs +++ b/thaw/src/popover/mod.rs @@ -91,13 +91,14 @@ pub fn Popover( let Some(target_el) = target_ref.get() else { return; }; - add_event_listener(target_el.into(), ev::click, move |event| { + let handler = add_event_listener(target_el.into(), ev::click, move |event| { if trigger_type != PopoverTriggerType::Click { return; } event.stop_propagation(); is_show_popover.update(|show| *show = !*show); }); + on_cleanup(move || handler.remove()); }); let PopoverTrigger { diff --git a/thaw_utils/src/lib.rs b/thaw_utils/src/lib.rs index 05c08fe..7e79cc3 100644 --- a/thaw_utils/src/lib.rs +++ b/thaw_utils/src/lib.rs @@ -1,18 +1,20 @@ +mod callback; pub mod class_list; mod dom; mod event_listener; mod hooks; pub mod macros; +mod on_click_outside; mod optional_prop; mod signals; mod throttle; mod time; -mod callback; -pub use dom::*; pub use callback::*; +pub use dom::*; pub use event_listener::{add_event_listener, add_event_listener_with_bool, EventListenerHandle}; pub use hooks::{use_click_position, use_lock_html_scroll, NextFrame}; +pub use on_click_outside::*; pub use optional_prop::OptionalProp; pub use signals::{ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal}; pub use throttle::throttle; diff --git a/thaw_utils/src/on_click_outside.rs b/thaw_utils/src/on_click_outside.rs new file mode 100644 index 0000000..85ba124 --- /dev/null +++ b/thaw_utils/src/on_click_outside.rs @@ -0,0 +1,34 @@ +use leptos::{ev, html::Div, prelude::*}; +use tachys::reactive_graph::node_ref::NodeRef; + +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()); + } + #[cfg(not(any(feature = "csr", feature = "hydrate")))] + { + let _ = element; + let _ = on_click; + } +}