dioxus_ui_system/molecules/
hover_card.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Default, Clone, PartialEq)]
12pub enum Side {
13 Top,
15 Right,
17 #[default]
19 Bottom,
20 Left,
22}
23
24#[derive(Default, Clone, PartialEq)]
26pub enum Align {
27 Start,
29 #[default]
31 Center,
32 End,
34}
35
36#[derive(Props, Clone, PartialEq)]
38pub struct HoverCardProps {
39 pub trigger: Element,
41 pub children: Element,
43 #[props(default = 200)]
45 pub open_delay: u64,
46 #[props(default = 100)]
48 pub close_delay: u64,
49 #[props(default)]
51 pub side: Side,
52 #[props(default)]
54 pub align: Align,
55 #[props(default)]
57 pub style: Option<String>,
58}
59
60#[component]
72pub fn HoverCard(props: HoverCardProps) -> Element {
73 let _theme = use_theme();
74 let mut is_visible = use_signal(|| false);
75
76 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 let _open_delay_ms = props.open_delay;
102 let _close_delay_ms = props.close_delay;
103 let card_style = use_style(move |t| {
104 let _ = _open_delay_ms; 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 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 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 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 div {
178 style: "display: inline-block;",
179 {props.trigger}
180 }
181
182 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 div {
194 style: "{arrow_style} {arrow_position}",
195 }
196
197 div {
199 style: "position: relative; z-index: 1;",
200 {props.children}
201 }
202 }
203 }
204 }
205}
206
207#[derive(Props, Clone, PartialEq)]
209pub struct HoverCardHeaderProps {
210 pub title: String,
212 #[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#[derive(Props, Clone, PartialEq)]
250pub struct HoverCardContentProps {
251 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#[derive(Props, Clone, PartialEq)]
271pub struct HoverCardFooterProps {
272 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#[derive(Props, Clone, PartialEq)]
302pub struct HoverCardAvatarProps {
303 pub src: String,
305 #[props(default)]
307 pub alt: Option<String>,
308 #[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#[derive(Props, Clone, PartialEq)]
329pub struct HoverCardUserInfoProps {
330 pub name: String,
332 pub handle: String,
334 #[props(default)]
336 pub avatar_url: Option<String>,
337 #[props(default)]
339 pub bio: Option<String>,
340 #[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}