Fluent/menu (#218)

* dropdown - fix some errors

* fluent dropdown

* mobile menu

* dropdown - fix some errors

* rename dropdown to menu

* fix: error caused by Callback in Menu

* change order

---------

Co-authored-by: luoxiao <luoxiaozero@163.com>
This commit is contained in:
kandrelczyk 2024-07-24 13:22:46 +00:00 committed by GitHub
parent 7da18dc9e5
commit a7918ef2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 613 additions and 27 deletions

View file

@ -78,6 +78,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
<Route path=StaticSegment("dialog") view=DialogMdPage/>
<Route path=StaticSegment("divider") view=DividerMdPage/>
<Route path=StaticSegment("drawer") view=DrawerMdPage/>
<Route path=StaticSegment("menu") view=MenuMdPage/>
<Route path=StaticSegment("grid") view=GridMdPage/>
<Route path=StaticSegment("icon") view=IconMdPage/>
<Route path=StaticSegment("image") view=ImageMdPage/>

View file

@ -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 {
<img src="/logo.svg" style="width: 36px"/>
<div class="demo-name">"Thaw UI"</div>
</Space>
<Space>
<Space align=SpaceAlign::Center>
<AutoComplete
value=search_value
placeholder="Type '/' to search"
@ -159,30 +160,47 @@ pub fn SiteHeader() -> impl IntoView {
/>
</AutoCompletePrefix>
</AutoComplete>
<Popover
placement=PopoverPlacement::BottomEnd
class="demo-header__menu-popover-mobile"
<Menu
placement=MenuPlacement::BottomEnd
on_select=move |value : String| match value.as_str() {
"Dark" => 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())
}
>
<PopoverTrigger slot class="demo-header__menu-mobile">
<Button
<MenuTrigger slot class="demo-header__menu-mobile">
<Button
appearance=ButtonAppearance::Subtle
icon=icondata::AiUnorderedListOutlined
attr:style="font-size: 22px; padding: 0px 6px;"
/>
</PopoverTrigger>
<div style="height: 70vh; overflow: auto;">// <Menu value=menu_value>
// <MenuItem key=theme_name label=theme_name />
// <MenuItem key="github" label="Github" />
// {
// use crate::pages::{gen_guide_menu_data, gen_menu_data};
// vec![
// gen_guide_menu_data().into_view(),
// gen_menu_data().into_view(),
// ]
// }
// </Menu>
</div>
</Popover>
</MenuTrigger>
<MenuItem key=theme_name label=theme_name/>
<MenuItem icon=icondata::AiGithubOutlined key="github" label="Github"/>
<MenuItem icon=icondata::BiDiscordAlt key="discord" label="Discord"/>
{
use crate::pages::{gen_menu_data, MenuGroupOption, MenuItemOption};
gen_menu_data().into_iter().map(|data| {
let MenuGroupOption { label, children } = data;
view! {
<Caption1Strong style="margin-inline-start: 10px; margin-top: 10px; display: block">
{label}
</Caption1Strong>
{
children.into_iter().map(|item| {
let MenuItemOption { label, value } = item;
view! {
<MenuItem label key=value/>
}
}).collect_view()
}
}
}).collect_view()
}
</Menu>
<Space class="demo-header__right-btn" align=SpaceAlign::Center>
<Button
appearance=ButtonAppearance::Subtle

View file

@ -229,6 +229,10 @@ pub(crate) fn gen_menu_data() -> Vec<MenuGroupOption> {
value: "/components/loading-bar".into(),
label: "Loading Bar".into(),
},
MenuItemOption {
value: "/components/menu".into(),
label: "Menu".into(),
},
MenuItemOption {
value: "/components/message-bar".into(),
label: "Message Bar".into(),

View file

@ -0,0 +1,183 @@
# Menu
```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(),),
// _ => ()
// }
//};
let on_select = move |key| println!("{}", key);
view! {
<Space>
<Menu on_select trigger_type=MenuTriggerType::Hover>
<MenuTrigger slot>
<Button>"Hover"</Button>
</MenuTrigger>
<MenuItem key="facebook" icon=icondata::AiFacebookOutlined label="Facebook"></MenuItem>
<MenuItem key="twitter" disabled=true icon=icondata::AiTwitterOutlined label="Twitter"></MenuItem>
</Menu>
<Menu on_select>
<MenuTrigger slot>
<Button>"Click"</Button>
</MenuTrigger>
<MenuItem key="facebook" icon=icondata::AiFacebookOutlined label="Facebook"></MenuItem>
<MenuItem key="twitter" icon=icondata::AiTwitterOutlined label="Twitter"></MenuItem>
<MenuItem key="no_icon" disabled=true label="Mastodon"></MenuItem>
</Menu>
</Space>
}
```
### Placement
```rust demo
use leptos_meta::Style;
let on_select = move |key| println!("{}", key);
view! {
<Style>
".demo-menu .thaw-button { width: 100% } .demo-menu .thaw-menu-trigger { display: block }"
</Style>
<Grid x_gap=8 y_gap=8 cols=3 class="demo-menu">
<GridItem>
<Menu on_select placement=MenuPlacement::TopStart>
<MenuTrigger slot>
<Button>"Top Start"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::Top>
<MenuTrigger slot>
<Button>"Top"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::TopEnd>
<MenuTrigger slot>
<Button>"Top End"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::LeftStart>
<MenuTrigger slot>
<Button>"Left Start"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem offset=1>
<Menu on_select placement=MenuPlacement::RightStart>
<MenuTrigger slot>
<Button>"Right Start"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::Left>
<MenuTrigger slot>
<Button>"Left"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem offset=1>
<Menu on_select placement=MenuPlacement::Right>
<MenuTrigger slot>
<Button>"Right"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::LeftEnd>
<MenuTrigger slot>
<Button>"Left End"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem offset=1>
<Menu on_select placement=MenuPlacement::RightEnd>
<MenuTrigger slot>
<Button>"Right End"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::BottomStart>
<MenuTrigger slot>
<Button>"Bottom Start"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::Bottom>
<MenuTrigger slot>
<Button>"Bottom"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
<GridItem>
<Menu on_select placement=MenuPlacement::BottomEnd>
<MenuTrigger slot>
<Button>"Bottom End"</Button>
</MenuTrigger>
"Content"
</Menu>
</GridItem>
</Grid>
}
```
### Menu Props
| Name | Type | Default | Description |
| ------------ | ----------------------------------- | ---------------------------- | ------------------------------------------- |
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Addtional classes for the menu element. |
| on_select | `Callback<String>` | | 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<MaybeSignal<String>>` | `Default::default()` | Addtional classes for the menu item element. |
| key | `MaybeSignal<String>` | `Default::default()` | The key of the menu item. |
| label | `MaybeSignal<String>` | `Default::default()` | The label of the menu item. |
| icon | `OptionalMaybeSignal<icondata_core::Icon>` | `None` | The icon of the menu item. |
| disabled | `MaybeSignal<bool>` | `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<MaybeSignal<String>>` | `Default::default()` | Addtional classes for the menu trigger element. |
| children | `Children` | | The content inside menu trigger. |

View file

@ -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",

View file

@ -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::*;

View file

@ -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;
}

68
thaw/src/menu/menu.css Normal file
View file

@ -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);
}

View file

@ -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<icondata_core::Icon>,
#[prop(into)] label: MaybeSignal<String>,
#[prop(into)] key: MaybeSignal<String>,
#[prop(optional, into)] disabled: MaybeSignal<bool>,
#[prop(optional, into)] class: OptionalProp<MaybeSignal<String>>,
) -> impl IntoView {
mount_style("menu-item", include_str!("./menu-item.css"));
let has_icon = use_context::<HasIcon>().expect("HasIcon not provided").0;
if icon.get().is_some() {
has_icon.set(true);
}
let on_select = use_context::<OnSelect>().expect("OnSelect not provided").0;
let on_click = move |_| {
if disabled.get() {
return;
}
on_select(key.get());
};
view! {
<div
class=class_list![
"thaw-menu-item", ("thaw-menu-item--disabled", move || disabled.get()),
class.map(| c | move || c.get())
]
on:click=on_click
>
<OptionComp value=icon.get() let:icon>
<Fallback slot>
<If cond=has_icon>
<Then slot>
<span style="width: 18px; margin-right: 8px"></span>
</Then>
</If>
</Fallback>
<Icon icon=icon style="font-size: 18px; margin-right: 8px"/>
</OptionComp>
<span style="flex-grow: 1">{label}</span>
</div>
}
}

199
thaw/src/menu/mod.rs Normal file
View file

@ -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<MaybeSignal<String>>,
children: Children,
}
#[derive(Copy, Clone)]
struct HasIcon(RwSignal<bool>);
#[derive(Clone)]
struct OnSelect(ArcOneCallback<String>);
#[component]
pub fn Menu(
#[prop(optional, into)] class: MaybeProp<String>,
menu_trigger: MenuTrigger,
#[prop(optional)] trigger_type: MenuTriggerType,
#[prop(optional)] placement: MenuPlacement,
#[prop(into)] on_select: ArcOneCallback<String>,
#[prop(optional, into)] appearance: Option<MaybeSignal<MenuAppearance>>,
children: Children,
) -> impl IntoView {
mount_style("menu", include_str!("./menu.css"));
let config_provider = ConfigInjection::use_();
let menu_ref = NodeRef::<Div>::new();
let target_ref = NodeRef::<Div>::new();
let is_show_menu = RwSignal::new(false);
let show_menu_handle = StoredValue::new(None::<TimeoutHandle>);
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::<String>::new(move |key| {
is_show_menu.set(false);
on_select(key);
})));
view! {
<Binder target_ref>
<div
class=class_list!["thaw-menu-trigger", trigger_class.map(| c | move || c.get())]
node_ref=target_ref
on:mouseenter=on_mouse_enter
on:mouseleave=on_mouse_leave
>
{trigger_children()}
</div>
<Follower slot show=is_show_menu placement>
<CSSTransition
node_ref=menu_ref
name="menu-transition"
appear=is_show_menu.get_untracked()
show=is_show_menu
let:display
>
<div
class=class_list![
"thaw-config-provider thaw-menu",
appearance.map(|appearance| move || format!("thaw-menu--{}", appearance.get().as_str())),
class
]
data-thaw-id=config_provider.id().clone()
style=move || display.get().unwrap_or_default()
node_ref=menu_ref
on:mouseenter=on_mouse_enter
on:mouseleave=on_mouse_leave
>
{children()}
</div>
</CSSTransition>
</Follower>
</Binder>
}
}
#[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<MenuPlacement> 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,
}
}
}

View file

@ -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 {

View file

@ -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;

View file

@ -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<Div>, 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<web_sys::Element> =
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;
}
}