impulse_thaw/menu/
mod.rs

1mod menu_item;
2
3pub use menu_item::*;
4
5use leptos::{
6    context::Provider,
7    either::Either,
8    ev::{self, on},
9    html::Div,
10    leptos_dom::helpers::TimeoutHandle,
11    prelude::*,
12    tachys::html::{class::class as tachys_class, node_ref::node_ref},
13};
14use std::time::Duration;
15use thaw_components::{Follower, FollowerPlacement};
16use thaw_utils::{class_list, mount_style, on_click_outside, ArcOneCallback, BoxOneCallback};
17
18#[slot]
19pub struct MenuTrigger<T> {
20    children: TypedChildren<T>,
21}
22
23#[component]
24pub fn Menu<T>(
25    #[prop(optional, into)] class: MaybeProp<String>,
26    /// The element or component that triggers menu.
27    menu_trigger: MenuTrigger<T>,
28    /// Action that displays the menu.
29    #[prop(optional)]
30    trigger_type: MenuTriggerType,
31    /// Menu position.
32    #[prop(optional)]
33    position: MenuPosition,
34    /// Called when item is selected.
35    #[prop(into)]
36    on_select: BoxOneCallback<String>,
37    #[prop(optional, into)] appearance: MaybeProp<MenuAppearance>,
38    children: Children,
39) -> impl IntoView
40where
41    T: AddAnyAttr + IntoView + Send + 'static,
42{
43    mount_style("menu", include_str!("./menu.css"));
44
45    let menu_ref = NodeRef::<Div>::new();
46    let is_show_menu = RwSignal::new(false);
47    let show_menu_handle = StoredValue::new(None::<TimeoutHandle>);
48
49    let on_mouse_enter = move |_| {
50        if trigger_type != MenuTriggerType::Hover {
51            return;
52        }
53        show_menu_handle.update_value(|handle| {
54            if let Some(handle) = handle.take() {
55                handle.clear();
56            }
57        });
58        is_show_menu.set(true);
59    };
60    let on_mouse_leave = move |_| {
61        if trigger_type != MenuTriggerType::Hover {
62            return;
63        }
64        show_menu_handle.update_value(|handle| {
65            if let Some(handle) = handle.take() {
66                handle.clear();
67            }
68            *handle = set_timeout_with_handle(
69                move || {
70                    is_show_menu.set(false);
71                },
72                Duration::from_millis(100),
73            )
74            .ok();
75        });
76    };
77
78    let MenuTrigger {
79        children: trigger_children,
80    } = menu_trigger;
81    let trigger_children = trigger_children.into_inner()()
82        .into_inner()
83        .add_any_attr(tachys_class(("thaw-menu-trigger", true)));
84
85    let trigger_children = match trigger_type {
86        MenuTriggerType::Click => {
87            let trigger_ref = NodeRef::<thaw_utils::Element>::new();
88            on_click_outside(
89                move || {
90                    if !is_show_menu.get_untracked() {
91                        return None;
92                    }
93                    let Some(trigger_el) = trigger_ref.get_untracked() else {
94                        return None;
95                    };
96                    let Some(menu_el) = menu_ref.get_untracked() else {
97                        return None;
98                    };
99                    Some(vec![menu_el.into(), trigger_el])
100                },
101                move || is_show_menu.set(false),
102            );
103            Either::Left(
104                trigger_children
105                    .add_any_attr(node_ref(trigger_ref))
106                    .add_any_attr(on(ev::click, move |_| {
107                        is_show_menu.update(|show| {
108                            *show = !*show;
109                        });
110                    })),
111            )
112        }
113        MenuTriggerType::Hover => Either::Right(
114            trigger_children
115                .add_any_attr(on(ev::mouseenter, on_mouse_enter))
116                .add_any_attr(on(ev::mouseleave, on_mouse_leave)),
117        ),
118    };
119
120    let menu_injection = MenuInjection {
121        has_icon: RwSignal::new(false),
122        on_select: ArcOneCallback::new(move |value| {
123            is_show_menu.set(false);
124            on_select(value);
125        }),
126    };
127
128    view! {
129        <crate::_binder::Binder>
130            {trigger_children} <Follower slot show=is_show_menu placement=position auto_height=true>
131                <div
132                    class=class_list![
133                        "thaw-menu",
134                        move || appearance.get().map(|a| format!("thaw-menu--{}", a.as_str())),
135                        class
136                    ]
137                    node_ref=menu_ref
138                    on:mouseenter=on_mouse_enter
139                    on:mouseleave=on_mouse_leave
140                >
141                    <Provider value=menu_injection>{children()}</Provider>
142                </div>
143            </Follower>
144        </crate::_binder::Binder>
145    }
146}
147
148#[derive(Clone)]
149pub(crate) struct MenuInjection {
150    has_icon: RwSignal<bool>,
151    on_select: ArcOneCallback<String>,
152}
153
154impl MenuInjection {
155    pub fn expect_context() -> Self {
156        expect_context()
157    }
158}
159
160#[derive(Default, PartialEq, Clone)]
161pub enum MenuTriggerType {
162    Hover,
163    #[default]
164    Click,
165}
166
167impl Copy for MenuTriggerType {}
168
169#[derive(Clone)]
170pub enum MenuAppearance {
171    Brand,
172    Inverted,
173}
174
175impl MenuAppearance {
176    pub fn as_str(&self) -> &'static str {
177        match self {
178            MenuAppearance::Brand => "brand",
179            MenuAppearance::Inverted => "inverted",
180        }
181    }
182}
183
184#[derive(Default)]
185pub enum MenuPosition {
186    Top,
187    #[default]
188    Bottom,
189    Left,
190    Right,
191    TopStart,
192    TopEnd,
193    LeftStart,
194    LeftEnd,
195    RightStart,
196    RightEnd,
197    BottomStart,
198    BottomEnd,
199}
200
201impl From<MenuPosition> for FollowerPlacement {
202    fn from(value: MenuPosition) -> Self {
203        match value {
204            MenuPosition::Top => Self::Top,
205            MenuPosition::Bottom => Self::Bottom,
206            MenuPosition::Left => Self::Left,
207            MenuPosition::Right => Self::Right,
208            MenuPosition::TopStart => Self::TopStart,
209            MenuPosition::TopEnd => Self::TopEnd,
210            MenuPosition::LeftStart => Self::LeftStart,
211            MenuPosition::LeftEnd => Self::LeftEnd,
212            MenuPosition::RightStart => Self::RightStart,
213            MenuPosition::RightEnd => Self::RightEnd,
214            MenuPosition::BottomStart => Self::BottomStart,
215            MenuPosition::BottomEnd => Self::BottomEnd,
216        }
217    }
218}