radix_leptos_primitives/components/
tooltip.rs1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum TooltipVariant {
53 Default,
54 Destructive,
55 Warning,
56 Info,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub enum TooltipSize {
61 Default,
62 Sm,
63 Lg,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub enum TooltipPosition {
68 Top,
69 Bottom,
70 Left,
71 Right,
72}
73
74impl TooltipVariant {
75 pub fn as_str(&self) -> &'static str {
76 match self {
77 TooltipVariant::Default => "default",
78 TooltipVariant::Destructive => "destructive",
79 TooltipVariant::Warning => "warning",
80 TooltipVariant::Info => "info",
81 }
82 }
83}
84
85impl TooltipSize {
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 TooltipSize::Default => "default",
89 TooltipSize::Sm => "sm",
90 TooltipSize::Lg => "lg",
91 }
92 }
93}
94
95impl TooltipPosition {
96 pub fn as_str(&self) -> &'static str {
97 match self {
98 TooltipPosition::Top => "top",
99 TooltipPosition::Bottom => "bottom",
100 TooltipPosition::Left => "left",
101 TooltipPosition::Right => "right",
102 }
103 }
104}
105
106fn generate_id(prefix: &str) -> String {
108 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
109 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
110 format!("{}-{}", prefix, id)
111}
112
113fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
115 match (existing, additional) {
116 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
117 (Some(a), None) => Some(a.to_string()),
118 (None, Some(b)) => Some(b.to_string()),
119 (None, None) => None,
120 }
121}
122
123#[component]
125pub fn Tooltip(
126 #[prop(optional, default = false)]
128 open: bool,
129 #[prop(optional, default = false)]
131 disabled: bool,
132 #[prop(optional, default = TooltipVariant::Default)]
134 variant: TooltipVariant,
135 #[prop(optional, default = TooltipSize::Default)]
137 size: TooltipSize,
138 #[prop(optional, default = TooltipPosition::Top)]
140 position: TooltipPosition,
141 #[prop(optional, default = 500)]
143 delay: u32,
144 #[prop(optional, default = 300)]
146 duration: u32,
147 #[prop(optional)]
149 class: Option<String>,
150 #[prop(optional)]
152 style: Option<String>,
153 #[prop(optional)]
155 onopen_change: Option<Callback<bool>>,
156 children: Children,
158) -> impl IntoView {
159 let __tooltip_id = generate_id("tooltip");
160 let trigger_id = generate_id("tooltip-trigger");
161 let content_id = generate_id("tooltip-content");
162
163 let data_variant = variant.as_str();
165 let data_size = size.as_str();
166 let data_position = position.as_str();
167
168 let base_classes = "radix-tooltip";
170 let combined_class = merge_classes(Some(base_classes), class.as_deref())
171 .unwrap_or_else(|| base_classes.to_string());
172
173 let handle_keydown = move |e: web_sys::KeyboardEvent| match e.key().as_str() {
175 "Enter" | " " => {
176 e.prevent_default();
177 if !disabled {
178 if let Some(onopen_change) = onopen_change {
179 onopen_change.run(!open);
180 }
181 }
182 }
183 "Escape" => {
184 e.prevent_default();
185 if let Some(onopen_change) = onopen_change {
186 onopen_change.run(false);
187 }
188 }
189 _ => {}
190 };
191
192 view! {
193 <div
194 class=combined_class
195 style=style
196 data-variant=data_variant
197 data-size=data_size
198 data-position=data_position
199 data-open=open
200 data-disabled=disabled
201 data-delay=delay
202 data-duration=duration
203 on:keydown=handle_keydown
204 >
205 {children()}
206 </div>
207 }
208}
209
210#[component]
212pub fn TooltipTrigger(
213 #[prop(optional)]
215 class: Option<String>,
216 #[prop(optional)]
218 style: Option<String>,
219 children: Children,
221) -> impl IntoView {
222 let trigger_id = generate_id("tooltip-trigger");
223
224 let base_classes = "radix-tooltip-trigger";
225 let combined_class = merge_classes(Some(base_classes), class.as_deref())
226 .unwrap_or_else(|| base_classes.to_string());
227
228 let handle_mouse_enter = move |e: web_sys::MouseEvent| {
230 e.prevent_default();
231 };
233
234 let handle_mouse_leave = move |e: web_sys::MouseEvent| {
235 e.prevent_default();
236 };
238
239 let handle_focus = move |e: web_sys::FocusEvent| {
241 e.prevent_default();
242 };
244
245 let handle_blur = move |e: web_sys::FocusEvent| {
246 e.prevent_default();
247 };
249
250 view! {
251 <div
252 class=combined_class
253 style=style
254 id=trigger_id
255 aria-describedby="tooltip-content"
256 on:mouseenter=handle_mouse_enter
257 on:mouseleave=handle_mouse_leave
258 on:focus=handle_focus
259 on:blur=handle_blur
260 >
261 {children()}
262 </div>
263 }
264}
265
266#[component]
268pub fn TooltipContent(
269 #[prop(optional)]
271 class: Option<String>,
272 #[prop(optional)]
274 style: Option<String>,
275 children: Children,
277) -> impl IntoView {
278 let content_id = generate_id("tooltip-content");
279
280 let base_classes = "radix-tooltip-content";
281 let combined_class = merge_classes(Some(base_classes), class.as_deref())
282 .unwrap_or_else(|| base_classes.to_string());
283
284 view! {
285 <div
286 class=combined_class
287 style=style
288 id=content_id
289 role="tooltip"
290 data-state="closed"
291 >
292 {children()}
293 </div>
294 }
295}
296
297#[component]
299pub fn TooltipArrow(
300 #[prop(optional)]
302 class: Option<String>,
303 #[prop(optional)]
305 style: Option<String>,
306) -> impl IntoView {
307 let base_classes = "radix-tooltip-arrow";
308 let combined_class = merge_classes(Some(base_classes), class.as_deref())
309 .unwrap_or_else(|| base_classes.to_string());
310
311 view! {
312 <div
313 class=combined_class
314 style=style
315 data-popper-arrow=""
316 >
317 </div>
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use crate::{TooltipPosition, TooltipSize, TooltipVariant};
324 use proptest::prelude::*;
325
326 #[test]
328 fn test_tooltip_variants() {
329 run_test(|| {
330 let variants = [
332 TooltipVariant::Default,
333 TooltipVariant::Destructive,
334 TooltipVariant::Warning,
335 TooltipVariant::Info,
336 ];
337
338 for variant in variants {
339 assert!(!variant.as_str().is_empty());
341 }
342 });
343 }
344
345 #[test]
346 fn test_tooltip_sizes() {
347 run_test(|| {
348 let sizes = [TooltipSize::Default, TooltipSize::Sm, TooltipSize::Lg];
349
350 for size in sizes {
351 assert!(!size.as_str().is_empty());
353 }
354 });
355 }
356
357 #[test]
358 fn test_tooltip_positions() {
359 run_test(|| {
360 let positions = [
361 TooltipPosition::Top,
362 TooltipPosition::Bottom,
363 TooltipPosition::Left,
364 TooltipPosition::Right,
365 ];
366
367 for position in positions {
368 assert!(!position.as_str().is_empty());
370 }
371 });
372 }
373
374 #[test]
376 fn test_tooltipopen_state() {
377 run_test(|| {
378 let open = true;
380 let disabled = false;
381 let variant = TooltipVariant::Default;
382 let size = TooltipSize::Default;
383 let position = TooltipPosition::Top;
384 let delay = 500;
385 let duration = 300;
386
387 assert!(open);
389 assert!(!disabled);
390 assert_eq!(variant, TooltipVariant::Default);
391 assert_eq!(size, TooltipSize::Default);
392 assert_eq!(position, TooltipPosition::Top);
393 assert_eq!(delay, 500);
394 assert_eq!(duration, 300);
395 });
396 }
397
398 #[test]
399 fn test_tooltip_closed_state() {
400 run_test(|| {
401 let open = false;
403 let disabled = true;
404 let variant = TooltipVariant::Destructive;
405 let size = TooltipSize::Lg;
406 let position = TooltipPosition::Bottom;
407 let delay = 1000;
408 let duration = 500;
409
410 assert!(!open);
412 assert!(disabled);
413 assert_eq!(variant, TooltipVariant::Destructive);
414 assert_eq!(size, TooltipSize::Lg);
415 assert_eq!(position, TooltipPosition::Bottom);
416 assert_eq!(delay, 1000);
417 assert_eq!(duration, 500);
418 });
419 }
420
421 #[test]
423 fn test_tooltip_state_changes() {
424 run_test(|| {
425 let mut open = false;
427 let disabled = false;
428 let delay = 500;
429 let duration = 300;
430
431 assert!(!open);
433 assert!(!disabled);
434 assert_eq!(delay, 500);
435 assert_eq!(duration, 300);
436
437 open = true;
439
440 assert!(open);
441 assert!(!disabled);
442 assert_eq!(delay, 500);
443 assert_eq!(duration, 300);
444
445 open = false;
447
448 assert!(!open);
449 assert!(!disabled);
450 assert_eq!(delay, 500);
451 assert_eq!(duration, 300);
452 });
453 }
454
455 #[test]
457 fn test_tooltip_keyboard_navigation() {
458 run_test(|| {
459 let enter_pressed = true;
461 let space_pressed = false;
462 let escape_pressed = false;
463 let disabled = false;
464
465 assert!(enter_pressed);
467 assert!(!space_pressed);
468 assert!(!escape_pressed);
469 assert!(!disabled);
470
471 if enter_pressed && !disabled {
473 }
475
476 if space_pressed && !disabled {
478 assert!(false); }
481
482 if escape_pressed {
484 assert!(false); }
487 });
488 }
489
490 #[test]
491 fn test_tooltip_mouse_events() {
492 run_test(|| {
493 let mouse_enter = true;
495 let mouse_leave = false;
496 let disabled = false;
497 let delay = 500;
498
499 assert!(mouse_enter);
501 assert!(!mouse_leave);
502 assert!(!disabled);
503 assert_eq!(delay, 500);
504
505 if mouse_enter && !disabled {
507 }
509
510 if mouse_leave && !disabled {
512 assert!(false); }
515 });
516 }
517
518 #[test]
519 fn test_tooltip_focus_events() {
520 run_test(|| {
521 let focus = true;
523 let blur = false;
524 let disabled = false;
525
526 assert!(focus);
528 assert!(!blur);
529 assert!(!disabled);
530
531 if focus && !disabled {
533 }
535
536 if blur && !disabled {
538 assert!(false); }
541 });
542 }
543
544 #[test]
546 fn test_tooltip_accessibility() {
547 run_test(|| {
548 let role = "tooltip";
550 let aria_describedby = "tooltip-content";
551 let data_state = "closed";
552
553 assert_eq!(role, "tooltip");
555 assert_eq!(aria_describedby, "tooltip-content");
556 assert_eq!(data_state, "closed");
557 });
558 }
559
560 #[test]
562 fn test_tooltip_edge_cases() {
563 run_test(|| {
564 let open = false;
566 let delay = 0;
567 let duration = 0;
568 let disabled = false;
569
570 assert!(!open);
572 assert_eq!(delay, 0);
573 assert_eq!(duration, 0);
574 assert!(!disabled);
575 });
576 }
577
578 #[test]
579 fn test_tooltipdisabled_state() {
580 run_test(|| {
581 let disabled = true;
583 let open = false;
584 let delay = 500;
585 let duration = 300;
586
587 assert!(disabled);
589 assert!(!open);
590 assert_eq!(delay, 500);
591 assert_eq!(duration, 300);
592
593 });
595 }
596
597 #[test]
598 fn test_tooltip_positioning() {
599 run_test(|| {
600 let position = TooltipPosition::Top;
602 let open = true;
603 let disabled = false;
604
605 assert_eq!(position, TooltipPosition::Top);
607 assert!(open);
608 assert!(!disabled);
609
610 let positions = [
612 TooltipPosition::Bottom,
613 TooltipPosition::Left,
614 TooltipPosition::Right,
615 ];
616
617 for pos in positions {
618 assert!(!pos.as_str().is_empty());
619 }
620 });
621 }
622
623 proptest! {
625 #[test]
626 fn test_tooltip_properties(
627 variant in prop::sample::select(&[
628 TooltipVariant::Default,
629 TooltipVariant::Destructive,
630 TooltipVariant::Warning,
631 TooltipVariant::Info,
632 ]),
633 size in prop::sample::select(&[
634 TooltipSize::Default,
635 TooltipSize::Sm,
636 TooltipSize::Lg,
637 ]),
638 position in prop::sample::select(&[
639 TooltipPosition::Top,
640 TooltipPosition::Bottom,
641 TooltipPosition::Left,
642 TooltipPosition::Right,
643 ]),
644 open in prop::bool::ANY,
645 disabled in prop::bool::ANY,
646 __delay in 0..2000u32,
647 __duration in 0..2000u32
648 ) {
649 assert!(!variant.as_str().is_empty());
652 assert!(!size.as_str().is_empty());
653 assert!(!position.as_str().is_empty());
654
655 assert!(open || !open);
657 assert!(disabled || !disabled);
658
659 assert!(__delay <= 2000);
661 assert!(__duration <= 2000);
662
663 if disabled {
665 }
668
669 if __delay > 1000 {
671 }
674 }
675 }
676
677 fn run_test<F>(f: F)
679 where
680 F: FnOnce(),
681 {
682 f();
684 }
685}