Skip to main content

dioxus_ui_system/molecules/
hover_card.rs

1//! Hover Card molecule component
2//!
3//! A card that appears when hovering over a trigger element.
4//! Similar to GitHub's user preview cards.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Side options for hover card placement
11#[derive(Default, Clone, PartialEq)]
12pub enum Side {
13    /// Top placement
14    Top,
15    /// Right placement
16    Right,
17    /// Bottom placement (default)
18    #[default]
19    Bottom,
20    /// Left placement
21    Left,
22}
23
24/// Alignment options for hover card
25#[derive(Default, Clone, PartialEq)]
26pub enum Align {
27    /// Start alignment
28    Start,
29    /// Center alignment (default)
30    #[default]
31    Center,
32    /// End alignment
33    End,
34}
35
36/// Hover Card properties
37#[derive(Props, Clone, PartialEq)]
38pub struct HoverCardProps {
39    /// Trigger element (the element that triggers the hover card)
40    pub trigger: Element,
41    /// Card content
42    pub children: Element,
43    /// Delay before showing the card (in ms)
44    #[props(default = 200)]
45    pub open_delay: u64,
46    /// Delay before hiding the card (in ms)
47    #[props(default = 100)]
48    pub close_delay: u64,
49    /// Card placement side
50    #[props(default)]
51    pub side: Side,
52    /// Card alignment
53    #[props(default)]
54    pub align: Align,
55    /// Custom inline styles for the card
56    #[props(default)]
57    pub style: Option<String>,
58}
59
60/// Hover Card component
61///
62/// A card that appears when hovering over a trigger element.
63/// Features:
64/// - Show on hover with configurable delay (using CSS)
65/// - Hide on mouse leave with configurable delay (using CSS)
66/// - Position relative to trigger (side + align)
67/// - Arrow pointing to trigger
68/// - Click outside to close
69/// - Escape key to close
70/// - Smooth fade animation
71#[component]
72pub fn HoverCard(props: HoverCardProps) -> Element {
73    let _theme = use_theme();
74    let mut is_visible = use_signal(|| false);
75
76    // Calculate position styles based on side and align
77    let position_style = match (&props.side, &props.align) {
78        (Side::Top, Align::Start) => "bottom: calc(100% + 8px); left: 0;",
79        (Side::Top, Align::Center) => {
80            "bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);"
81        }
82        (Side::Top, Align::End) => "bottom: calc(100% + 8px); right: 0;",
83        (Side::Right, Align::Start) => "left: calc(100% + 8px); top: 0;",
84        (Side::Right, Align::Center) => {
85            "left: calc(100% + 8px); top: 50%; transform: translateY(-50%);"
86        }
87        (Side::Right, Align::End) => "left: calc(100% + 8px); bottom: 0;",
88        (Side::Bottom, Align::Start) => "top: calc(100% + 8px); left: 0;",
89        (Side::Bottom, Align::Center) => {
90            "top: calc(100% + 8px); left: 50%; transform: translateX(-50%);"
91        }
92        (Side::Bottom, Align::End) => "top: calc(100% + 8px); right: 0;",
93        (Side::Left, Align::Start) => "right: calc(100% + 8px); top: 0;",
94        (Side::Left, Align::Center) => {
95            "right: calc(100% + 8px); top: 50%; transform: translateY(-50%);"
96        }
97        (Side::Left, Align::End) => "right: calc(100% + 8px); bottom: 0;",
98    };
99
100    // Card base styles with CSS-based hover delay
101    let _open_delay_ms = props.open_delay;
102    let _close_delay_ms = props.close_delay;
103    let card_style = use_style(move |t| {
104        // Note: open_delay_ms is stored but CSS-based delay not implemented
105        let _ = _open_delay_ms; // Acknowledge the parameter
106        Style::new()
107            .absolute()
108            .w_px(320)
109            .rounded(&t.radius, "md")
110            .border(1, &t.colors.border)
111            .bg(&t.colors.popover)
112            .shadow(&t.shadows.lg)
113            .z_index(9999)
114            .transition("opacity 200ms ease, transform 200ms ease")
115            .build()
116    });
117
118    // Arrow styles based on side
119    let arrow_style = use_style(|t| {
120        Style::new()
121            .absolute()
122            .w_px(8)
123            .h_px(8)
124            .bg(&t.colors.popover)
125            .border(1, &t.colors.border)
126            .build()
127    });
128
129    let arrow_position = match &props.side {
130        Side::Top => "bottom: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); border-top: none; border-left: none;",
131        Side::Right => "left: -4px; top: 50%; transform: translateY(-50%) rotate(45deg); border-right: none; border-bottom: none;",
132        Side::Bottom => "top: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); border-bottom: none; border-right: none;",
133        Side::Left => "right: -4px; top: 50%; transform: translateY(-50%) rotate(45deg); border-left: none; border-top: none;",
134    };
135
136    // Visibility styles with animation
137    let visibility_style = if is_visible() {
138        "opacity: 1; pointer-events: auto;"
139    } else {
140        "opacity: 0; pointer-events: none;"
141    };
142
143    let transform_style = match (&props.side, &props.align, is_visible()) {
144        (Side::Top, Align::Center, true) => "transform: translateX(-50%) translateY(0);",
145        (Side::Top, Align::Center, false) => "transform: translateX(-50%) translateY(4px);",
146        (Side::Right, Align::Center, true) => "transform: translateY(-50%) translateX(0);",
147        (Side::Right, Align::Center, false) => "transform: translateY(-50%) translateX(-4px);",
148        (Side::Bottom, Align::Center, true) => "transform: translateX(-50%) translateY(0);",
149        (Side::Bottom, Align::Center, false) => "transform: translateX(-50%) translateY(-4px);",
150        (Side::Left, Align::Center, true) => "transform: translateY(-50%) translateX(0);",
151        (Side::Left, Align::Center, false) => "transform: translateY(-50%) translateX(4px);",
152        (_, _, true) => "transform: translateY(0);",
153        (_, _, false) => "transform: translateY(4px);",
154    };
155
156    // Handle keyboard events (Escape to close)
157    let handle_keydown = move |e: Event<KeyboardData>| {
158        if e.key() == Key::Escape && is_visible() {
159            is_visible.set(false);
160        }
161    };
162
163    let custom_style = props.style.clone().unwrap_or_default();
164
165    rsx! {
166        div {
167            style: "position: relative; display: inline-block;",
168            onmouseenter: move |_| {
169                is_visible.set(true);
170            },
171            onmouseleave: move |_| {
172                is_visible.set(false);
173            },
174            onkeydown: handle_keydown,
175
176            // Trigger
177            div {
178                style: "display: inline-block;",
179                {props.trigger}
180            }
181
182            // Card content - sibling to trigger, not child of overlay
183            div {
184                style: "{card_style} {position_style} {visibility_style} {transform_style} {custom_style}",
185                onmouseenter: move |_| {
186                    is_visible.set(true);
187                },
188                onmouseleave: move |_| {
189                    is_visible.set(false);
190                },
191
192                // Arrow
193                div {
194                    style: "{arrow_style} {arrow_position}",
195                }
196
197                // Content wrapper
198                div {
199                    style: "position: relative; z-index: 1;",
200                    {props.children}
201                }
202            }
203        }
204    }
205}
206
207/// Hover Card Header component
208#[derive(Props, Clone, PartialEq)]
209pub struct HoverCardHeaderProps {
210    /// Header title
211    pub title: String,
212    /// Optional description
213    #[props(default)]
214    pub description: Option<String>,
215}
216
217#[component]
218pub fn HoverCardHeader(props: HoverCardHeaderProps) -> Element {
219    let _theme = use_theme();
220
221    let header_style = use_style(|t| {
222        Style::new()
223            .pb(&t.spacing, "md")
224            .mb(&t.spacing, "sm")
225            .border_bottom(1, &t.colors.border)
226            .build()
227    });
228
229    rsx! {
230        div {
231            style: "{header_style}",
232
233            h4 {
234                style: "margin: 0; font-size: 16px; font-weight: 600;",
235                "{props.title}"
236            }
237
238            if let Some(description) = props.description {
239                p {
240                    style: "margin: 4px 0 0 0; font-size: 13px; color: #64748b;",
241                    "{description}"
242                }
243            }
244        }
245    }
246}
247
248/// Hover Card Content component
249#[derive(Props, Clone, PartialEq)]
250pub struct HoverCardContentProps {
251    /// Content to display
252    pub children: Element,
253}
254
255#[component]
256pub fn HoverCardContent(props: HoverCardContentProps) -> Element {
257    let _theme = use_theme();
258
259    let content_style = use_style(|t| Style::new().p(&t.spacing, "md").build());
260
261    rsx! {
262        div {
263            style: "{content_style}",
264            {props.children}
265        }
266    }
267}
268
269/// Hover Card Footer component
270#[derive(Props, Clone, PartialEq)]
271pub struct HoverCardFooterProps {
272    /// Footer content
273    pub children: Element,
274}
275
276#[component]
277pub fn HoverCardFooter(props: HoverCardFooterProps) -> Element {
278    let _theme = use_theme();
279
280    let footer_style = use_style(|t| {
281        Style::new()
282            .pt(&t.spacing, "md")
283            .mt(&t.spacing, "sm")
284            .border_top(1, &t.colors.border)
285            .flex()
286            .justify_end()
287            .items_center()
288            .gap(&t.spacing, "sm")
289            .build()
290    });
291
292    rsx! {
293        div {
294            style: "{footer_style}",
295            {props.children}
296        }
297    }
298}
299
300/// Hover Card Avatar component for user previews
301#[derive(Props, Clone, PartialEq)]
302pub struct HoverCardAvatarProps {
303    /// Avatar image URL
304    pub src: String,
305    /// Avatar alt text
306    #[props(default)]
307    pub alt: Option<String>,
308    /// Avatar size in pixels
309    #[props(default = 40)]
310    pub size: u16,
311}
312
313#[component]
314pub fn HoverCardAvatar(props: HoverCardAvatarProps) -> Element {
315    let size = props.size;
316    let alt = props.alt.clone().unwrap_or_default();
317
318    rsx! {
319        img {
320            src: "{props.src}",
321            alt: "{alt}",
322            style: "width: {size}px; height: {size}px; border-radius: 50%; object-fit: cover;",
323        }
324    }
325}
326
327/// Hover Card User Info component for GitHub-style user previews
328#[derive(Props, Clone, PartialEq)]
329pub struct HoverCardUserInfoProps {
330    /// User name
331    pub name: String,
332    /// User handle/username
333    pub handle: String,
334    /// Optional avatar URL
335    #[props(default)]
336    pub avatar_url: Option<String>,
337    /// Optional bio/description
338    #[props(default)]
339    pub bio: Option<String>,
340    /// Optional stats (e.g., "10 repos ยท 50 followers")
341    #[props(default)]
342    pub stats: Option<String>,
343}
344
345#[component]
346pub fn HoverCardUserInfo(props: HoverCardUserInfoProps) -> Element {
347    let _theme = use_theme();
348
349    let container_style = use_style(|t| {
350        Style::new()
351            .flex()
352            .items_start()
353            .gap(&t.spacing, "md")
354            .build()
355    });
356
357    rsx! {
358        div {
359            style: "{container_style}",
360
361            if let Some(avatar_url) = props.avatar_url {
362                HoverCardAvatar {
363                    src: avatar_url,
364                    alt: Some(props.name.clone()),
365                    size: 48,
366                }
367            }
368
369            div {
370                style: "flex: 1; min-width: 0;",
371
372                div {
373                    style: "font-weight: 600; font-size: 15px;",
374                    "{props.name}"
375                }
376
377                div {
378                    style: "font-size: 13px; color: #64748b;",
379                    "{props.handle}"
380                }
381
382                if let Some(bio) = props.bio {
383                    p {
384                        style: "margin: 8px 0 0 0; font-size: 13px; line-height: 1.4;",
385                        "{bio}"
386                    }
387                }
388
389                if let Some(stats) = props.stats {
390                    div {
391                        style: "margin-top: 8px; font-size: 12px; color: #64748b;",
392                        "{stats}"
393                    }
394                }
395            }
396        }
397    }
398}