adui_dioxus/components/
tooltip.rs

1use crate::components::floating::use_floating_close_handle;
2use crate::components::overlay::OverlayKind;
3use crate::components::select_base::use_floating_layer;
4use dioxus::events::{KeyboardEvent, MouseEvent};
5use dioxus::prelude::Key;
6use dioxus::prelude::*;
7
8/// Placement of the tooltip bubble relative to the trigger.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum TooltipPlacement {
11    #[default]
12    Top,
13    Bottom,
14    Left,
15    Right,
16}
17
18/// Trigger mode for opening/closing the tooltip.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum TooltipTrigger {
21    #[default]
22    Hover,
23    Click,
24}
25
26/// Props for the Tooltip component (MVP subset).
27#[derive(Props, Clone, PartialEq)]
28pub struct TooltipProps {
29    /// Simple text title shown inside the tooltip when `content` is not set.
30    #[props(optional)]
31    pub title: Option<String>,
32    /// Custom tooltip content node.
33    #[props(optional)]
34    pub content: Option<Element>,
35    /// Placement of the tooltip relative to the trigger. Defaults to `Top`.
36    #[props(optional)]
37    pub placement: Option<TooltipPlacement>,
38    /// How the tooltip is triggered. Defaults to hover.
39    #[props(default)]
40    pub trigger: TooltipTrigger,
41    /// Controlled open state. When set, the component becomes controlled and
42    /// does not manage its own visibility.
43    #[props(optional)]
44    pub open: Option<bool>,
45    /// Initial open state when used in uncontrolled mode.
46    #[props(optional)]
47    pub default_open: Option<bool>,
48    /// Called when the open state changes due to user interaction.
49    #[props(optional)]
50    pub on_open_change: Option<EventHandler<bool>>,
51    /// Disable user interaction.
52    #[props(default)]
53    pub disabled: bool,
54    /// Extra class for the trigger wrapper.
55    #[props(optional)]
56    pub class: Option<String>,
57    /// Extra class for the tooltip bubble.
58    #[props(optional)]
59    pub overlay_class: Option<String>,
60    /// Inline styles applied to the tooltip bubble.
61    #[props(optional)]
62    pub overlay_style: Option<String>,
63    /// Trigger element.
64    pub children: Element,
65}
66
67/// Lightweight Ant Design flavored tooltip.
68#[component]
69pub fn Tooltip(props: TooltipProps) -> Element {
70    let TooltipProps {
71        title,
72        content,
73        placement,
74        trigger,
75        open,
76        default_open,
77        on_open_change,
78        disabled,
79        class,
80        overlay_class,
81        overlay_style,
82        children,
83    } = props;
84
85    let placement = placement.unwrap_or_default();
86
87    // Internal open state used only when the component is not controlled.
88    let open_state: Signal<bool> = use_signal(|| default_open.unwrap_or(false));
89    let is_controlled = open.is_some();
90    let current_open = open.unwrap_or(*open_state.read());
91
92    // Register with the overlay manager to obtain a z-index slot.
93    let floating = use_floating_layer(OverlayKind::Tooltip, current_open);
94    let current_z = *floating.z_index.read();
95
96    // Click-outside + Esc close helper is only needed for uncontrolled
97    // click-triggered tooltips. For controlled cases the parent is expected to
98    // handle visibility.
99    let close_handle = if !is_controlled && matches!(trigger, TooltipTrigger::Click) {
100        Some(use_floating_close_handle(open_state))
101    } else {
102        None
103    };
104
105    // Snapshots for event handlers.
106    let disabled_flag = disabled;
107    let is_controlled_flag = is_controlled;
108    let open_for_handlers = open_state;
109    let trigger_mode = trigger;
110    let current_open_flag = current_open;
111    let close_handle_for_click = close_handle;
112    let close_handle_for_content = close_handle;
113
114    let class_attr = {
115        let mut list = vec!["adui-tooltip-root".to_string()];
116        if let Some(extra) = class {
117            list.push(extra);
118        }
119        list.join(" ")
120    };
121
122    let overlay_class_attr = {
123        let mut list = vec!["adui-tooltip".to_string()];
124        if let Some(extra) = overlay_class {
125            list.push(extra);
126        }
127        list.join(" ")
128    };
129
130    let overlay_style_attr = {
131        let placement_css = match placement {
132            TooltipPlacement::Top => {
133                "bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 8px;"
134            }
135            TooltipPlacement::Bottom => {
136                "top: 100%; left: 50%; transform: translateX(-50%); margin-top: 8px;"
137            }
138            TooltipPlacement::Left => {
139                "right: 100%; top: 50%; transform: translateY(-50%); margin-right: 8px;"
140            }
141            TooltipPlacement::Right => {
142                "left: 100%; top: 50%; transform: translateY(-50%); margin-left: 8px;"
143            }
144        };
145        let extra = overlay_style.unwrap_or_default();
146        format!(
147            "position: absolute; z-index: {}; {}; {}",
148            current_z, placement_css, extra
149        )
150    };
151
152    let title_text = title.clone();
153    let content_node = content.clone();
154
155    rsx! {
156        span {
157            class: "{class_attr}",
158            style: "position: relative; display: inline-block;",
159            onmouseenter: move |_evt: MouseEvent| {
160                if matches!(trigger_mode, TooltipTrigger::Hover) {
161                    update_open_state(
162                        disabled_flag,
163                        is_controlled_flag,
164                        open_for_handlers,
165                        on_open_change,
166                        true,
167                    );
168                }
169            },
170            onmouseleave: move |_evt: MouseEvent| {
171                if matches!(trigger_mode, TooltipTrigger::Hover) {
172                    update_open_state(
173                        disabled_flag,
174                        is_controlled_flag,
175                        open_for_handlers,
176                        on_open_change,
177                        false,
178                    );
179                }
180            },
181            onclick: move |_evt: MouseEvent| {
182                if !matches!(trigger_mode, TooltipTrigger::Click) {
183                    return;
184                }
185                if let Some(handle) = close_handle_for_click {
186                    handle.mark_internal_click();
187                }
188                update_open_state(
189                    disabled_flag,
190                    is_controlled_flag,
191                    open_for_handlers,
192                    on_open_change,
193                    !current_open_flag,
194                );
195            },
196            {children}
197            if current_open {
198                div {
199                    class: "{overlay_class_attr}",
200                    style: "{overlay_style_attr}",
201                    tabindex: 0,
202                    onkeydown: move |evt: KeyboardEvent| {
203                        if matches!(evt.key(), Key::Escape) {
204                            evt.prevent_default();
205                            update_open_state(
206                                disabled_flag,
207                                is_controlled_flag,
208                                open_for_handlers,
209                                on_open_change,
210                                false,
211                            );
212                        }
213                    },
214                    onclick: move |_evt| {
215                        if let Some(handle) = close_handle_for_content {
216                            handle.mark_internal_click();
217                        }
218                    },
219                    div { class: "adui-tooltip-inner",
220                        if let Some(node) = content_node {
221                            {node}
222                        } else if let Some(text) = title_text {
223                            span { "{text}" }
224                        }
225                    }
226                }
227            }
228        }
229    }
230}
231
232pub fn update_open_state(
233    disabled: bool,
234    is_controlled: bool,
235    mut open_signal: Signal<bool>,
236    on_open_change: Option<EventHandler<bool>>,
237    next: bool,
238) {
239    if disabled {
240        return;
241    }
242
243    if is_controlled {
244        if let Some(cb) = on_open_change {
245            cb.call(next);
246        }
247    } else {
248        open_signal.set(next);
249        if let Some(cb) = on_open_change {
250            cb.call(next);
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn tooltip_placement_default() {
261        assert_eq!(TooltipPlacement::default(), TooltipPlacement::Top);
262    }
263
264    #[test]
265    fn tooltip_placement_all_variants() {
266        assert_eq!(TooltipPlacement::Top, TooltipPlacement::Top);
267        assert_eq!(TooltipPlacement::Bottom, TooltipPlacement::Bottom);
268        assert_eq!(TooltipPlacement::Left, TooltipPlacement::Left);
269        assert_eq!(TooltipPlacement::Right, TooltipPlacement::Right);
270        assert_ne!(TooltipPlacement::Top, TooltipPlacement::Bottom);
271        assert_ne!(TooltipPlacement::Top, TooltipPlacement::Left);
272        assert_ne!(TooltipPlacement::Top, TooltipPlacement::Right);
273        assert_ne!(TooltipPlacement::Bottom, TooltipPlacement::Left);
274        assert_ne!(TooltipPlacement::Bottom, TooltipPlacement::Right);
275        assert_ne!(TooltipPlacement::Left, TooltipPlacement::Right);
276    }
277
278    #[test]
279    fn tooltip_placement_clone() {
280        let original = TooltipPlacement::Right;
281        let cloned = original;
282        assert_eq!(original, cloned);
283    }
284
285    #[test]
286    fn tooltip_trigger_default() {
287        assert_eq!(TooltipTrigger::default(), TooltipTrigger::Hover);
288    }
289
290    #[test]
291    fn tooltip_trigger_all_variants() {
292        assert_eq!(TooltipTrigger::Hover, TooltipTrigger::Hover);
293        assert_eq!(TooltipTrigger::Click, TooltipTrigger::Click);
294        assert_ne!(TooltipTrigger::Hover, TooltipTrigger::Click);
295    }
296
297    #[test]
298    fn tooltip_trigger_clone() {
299        let original = TooltipTrigger::Click;
300        let cloned = original;
301        assert_eq!(original, cloned);
302    }
303}