adui_dioxus/components/
tour.rs

1//! Tour component for guided user onboarding.
2//!
3//! A popup component for guiding users through a product. It displays a series
4//! of steps, highlighting UI elements and providing descriptions.
5//!
6//! # Features
7//! - Step-by-step guidance with navigation
8//! - Customizable placement for each step
9//! - Highlight mask for focused elements
10//! - Keyboard navigation (arrow keys, Escape)
11//! - Primary and default visual variants
12//!
13//! # Example
14//! ```rust,ignore
15//! use adui_dioxus::components::tour::{Tour, TourStep};
16//!
17//! let steps = vec![
18//!     TourStep::new("step1", "Welcome", "This is the first step"),
19//!     TourStep::new("step2", "Feature", "Explore this feature"),
20//! ];
21//!
22//! rsx! {
23//!     Tour {
24//!         open: show_tour(),
25//!         steps: steps,
26//!         on_close: move |_| set_show_tour(false),
27//!         on_finish: move |_| { /* tour completed */ },
28//!     }
29//! }
30//! ```
31
32use crate::components::button::{Button, ButtonColor, ButtonVariant};
33use crate::components::overlay::{OverlayKey, OverlayKind, use_overlay};
34use crate::components::tooltip::TooltipPlacement;
35use crate::theme::use_theme;
36use dioxus::events::KeyboardEvent;
37use dioxus::prelude::*;
38
39/// Visual type of the tour.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum TourType {
42    /// Default style with light background.
43    #[default]
44    Default,
45    /// Primary style with colored background.
46    Primary,
47}
48
49impl TourType {
50    fn as_class(&self) -> &'static str {
51        match self {
52            TourType::Default => "adui-tour-default",
53            TourType::Primary => "adui-tour-primary",
54        }
55    }
56}
57
58/// Data model for a single tour step.
59#[derive(Clone, PartialEq)]
60pub struct TourStep {
61    /// Unique key for the step.
62    pub key: String,
63    /// Title displayed in the tour panel.
64    pub title: Option<String>,
65    /// Description text or element.
66    pub description: Option<Element>,
67    /// Cover image or element displayed above the content.
68    pub cover: Option<Element>,
69    /// Placement of the tour panel relative to the target.
70    pub placement: Option<TooltipPlacement>,
71    /// CSS selector for the target element to highlight.
72    /// When None, the panel is centered on screen.
73    pub target: Option<String>,
74    /// Custom "Next" button text.
75    pub next_button_text: Option<String>,
76    /// Custom "Previous" button text.
77    pub prev_button_text: Option<String>,
78}
79
80impl TourStep {
81    /// Create a new tour step with title and description.
82    pub fn new(
83        key: impl Into<String>,
84        title: impl Into<String>,
85        description: impl Into<String>,
86    ) -> Self {
87        let desc_text = description.into();
88        Self {
89            key: key.into(),
90            title: Some(title.into()),
91            description: Some(rsx! { "{desc_text}" }),
92            cover: None,
93            placement: None,
94            target: None,
95            next_button_text: None,
96            prev_button_text: None,
97        }
98    }
99
100    /// Set the target CSS selector.
101    pub fn target(mut self, selector: impl Into<String>) -> Self {
102        self.target = Some(selector.into());
103        self
104    }
105
106    /// Set the placement of the tour panel.
107    pub fn placement(mut self, placement: TooltipPlacement) -> Self {
108        self.placement = Some(placement);
109        self
110    }
111
112    /// Set a cover element.
113    pub fn cover(mut self, cover: Element) -> Self {
114        self.cover = Some(cover);
115        self
116    }
117
118    /// Set custom description element.
119    pub fn description_element(mut self, desc: Element) -> Self {
120        self.description = Some(desc);
121        self
122    }
123
124    /// Set custom next button text.
125    pub fn next_button(mut self, text: impl Into<String>) -> Self {
126        self.next_button_text = Some(text.into());
127        self
128    }
129
130    /// Set custom prev button text.
131    pub fn prev_button(mut self, text: impl Into<String>) -> Self {
132        self.prev_button_text = Some(text.into());
133        self
134    }
135}
136
137/// Props for the Tour component.
138#[derive(Props, Clone, PartialEq)]
139pub struct TourProps {
140    /// Whether the tour is visible.
141    pub open: bool,
142    /// Tour steps to display.
143    pub steps: Vec<TourStep>,
144    /// Controlled current step index.
145    #[props(optional)]
146    pub current: Option<usize>,
147    /// Called when the tour is closed (via close button, mask click, or Escape).
148    #[props(optional)]
149    pub on_close: Option<EventHandler<()>>,
150    /// Called when the current step changes.
151    #[props(optional)]
152    pub on_change: Option<EventHandler<usize>>,
153    /// Called when the user completes the tour.
154    #[props(optional)]
155    pub on_finish: Option<EventHandler<()>>,
156    /// Visual type of the tour.
157    #[props(default)]
158    pub r#type: TourType,
159    /// Whether clicking the mask should close the tour.
160    #[props(default = true)]
161    pub mask_closable: bool,
162    /// Whether to show the close button.
163    #[props(default = true)]
164    pub closable: bool,
165    /// Whether to show step indicators.
166    #[props(default = true)]
167    pub show_indicators: bool,
168    /// Text for the "Next" button.
169    #[props(optional)]
170    pub next_button_text: Option<String>,
171    /// Text for the "Previous" button.
172    #[props(optional)]
173    pub prev_button_text: Option<String>,
174    /// Text for the "Finish" button.
175    #[props(optional)]
176    pub finish_button_text: Option<String>,
177    /// Additional CSS class on the root container.
178    #[props(optional)]
179    pub class: Option<String>,
180    /// Inline styles applied to the root container.
181    #[props(optional)]
182    pub style: Option<String>,
183}
184
185/// Ant Design flavored Tour component for user onboarding.
186#[component]
187pub fn Tour(props: TourProps) -> Element {
188    let TourProps {
189        open,
190        steps,
191        current,
192        on_close,
193        on_change,
194        on_finish,
195        r#type,
196        mask_closable,
197        closable,
198        show_indicators,
199        next_button_text,
200        prev_button_text,
201        finish_button_text,
202        class,
203        style,
204    } = props;
205
206    let theme = use_theme();
207    let tokens = theme.tokens();
208
209    // Overlay management for z-index
210    let overlay = use_overlay();
211    let tour_key: Signal<Option<OverlayKey>> = use_signal(|| None);
212    let z_index: Signal<i32> = use_signal(|| 1000);
213
214    {
215        let overlay = overlay.clone();
216        let mut key_signal = tour_key;
217        let mut z_signal = z_index;
218        use_effect(move || {
219            if let Some(handle) = overlay.clone() {
220                let current_key = *key_signal.read();
221                if open {
222                    if current_key.is_none() {
223                        let (key, meta) = handle.open(OverlayKind::Modal, true);
224                        z_signal.set(meta.z_index);
225                        key_signal.set(Some(key));
226                    }
227                } else if let Some(key) = current_key {
228                    handle.close(key);
229                    key_signal.set(None);
230                }
231            }
232        });
233    }
234
235    // Internal step state (uncontrolled mode)
236    let internal_current: Signal<usize> = use_signal(|| 0);
237    let is_controlled = current.is_some();
238    let current_step = current.unwrap_or_else(|| *internal_current.read());
239
240    // Reset internal state when tour opens
241    {
242        let mut internal = internal_current;
243        use_effect(move || {
244            if open && !is_controlled {
245                internal.set(0);
246            }
247        });
248    }
249
250    if !open || steps.is_empty() {
251        return rsx! {};
252    }
253
254    let total_steps = steps.len();
255    let step = steps.get(current_step).cloned();
256
257    let Some(step) = step else {
258        return rsx! {};
259    };
260
261    let current_z = *z_index.read();
262    let is_first = current_step == 0;
263    let is_last = current_step == total_steps - 1;
264
265    // Text for buttons
266    let prev_text = step
267        .prev_button_text
268        .as_ref()
269        .or(prev_button_text.as_ref())
270        .cloned()
271        .unwrap_or_else(|| "Previous".to_string());
272    let next_text = step
273        .next_button_text
274        .as_ref()
275        .or(next_button_text.as_ref())
276        .cloned()
277        .unwrap_or_else(|| "Next".to_string());
278    let finish_text = finish_button_text
279        .clone()
280        .unwrap_or_else(|| "Finish".to_string());
281
282    // Placement CSS
283    let placement = step.placement.unwrap_or(TooltipPlacement::Bottom);
284    let placement_style = match placement {
285        TooltipPlacement::Top => "bottom: 60%; left: 50%; transform: translateX(-50%);",
286        TooltipPlacement::Bottom => "top: 40%; left: 50%; transform: translateX(-50%);",
287        TooltipPlacement::Left => "right: 60%; top: 50%; transform: translateY(-50%);",
288        TooltipPlacement::Right => "left: 60%; top: 50%; transform: translateY(-50%);",
289    };
290
291    // Build root classes
292    let mut class_list = vec!["adui-tour".to_string(), r#type.as_class().to_string()];
293    if let Some(extra) = class {
294        class_list.push(extra);
295    }
296    let class_attr = class_list.join(" ");
297    let style_attr = style.unwrap_or_default();
298
299    // Panel background based on type
300    let panel_bg = match r#type {
301        TourType::Default => tokens.color_bg_container.clone(),
302        TourType::Primary => tokens.color_primary.clone(),
303    };
304    let panel_text = match r#type {
305        TourType::Default => tokens.color_text.clone(),
306        TourType::Primary => "#ffffff".to_string(),
307    };
308
309    let on_close_cb = on_close;
310    let on_change_cb = on_change;
311    let on_finish_cb = on_finish;
312
313    let handle_close = move || {
314        if let Some(cb) = on_close_cb {
315            cb.call(());
316        }
317    };
318
319    let handle_prev = {
320        let on_change = on_change_cb;
321        move || {
322            if current_step > 0 {
323                let next_step = current_step - 1;
324                if let Some(cb) = on_change {
325                    cb.call(next_step);
326                }
327                if !is_controlled {
328                    let mut sig = internal_current;
329                    sig.set(next_step);
330                }
331            }
332        }
333    };
334
335    let handle_next = {
336        let on_change = on_change_cb;
337        let on_finish = on_finish_cb;
338        move || {
339            if is_last {
340                if let Some(cb) = on_finish {
341                    cb.call(());
342                }
343                if let Some(cb) = on_close_cb {
344                    cb.call(());
345                }
346            } else {
347                let next_step = current_step + 1;
348                if let Some(cb) = on_change {
349                    cb.call(next_step);
350                }
351                if !is_controlled {
352                    let mut sig = internal_current;
353                    sig.set(next_step);
354                }
355            }
356        }
357    };
358
359    let handle_keydown = {
360        move |evt: KeyboardEvent| {
361            use dioxus::prelude::Key;
362
363            match evt.key() {
364                Key::Escape => {
365                    evt.prevent_default();
366                    handle_close();
367                }
368                Key::ArrowLeft => {
369                    evt.prevent_default();
370                    if !is_first {
371                        handle_prev();
372                    }
373                }
374                Key::ArrowRight | Key::Enter => {
375                    evt.prevent_default();
376                    handle_next();
377                }
378                _ => {}
379            }
380        }
381    };
382
383    rsx! {
384        // Mask layer
385        div {
386            class: "adui-tour-mask",
387            style: "position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: {current_z};",
388            onclick: move |_| {
389                if mask_closable {
390                    handle_close();
391                }
392            }
393        }
394        // Tour panel
395        div {
396            class: "{class_attr}",
397            style: "position: fixed; {placement_style} z-index: {current_z + 1}; {style_attr}",
398            tabindex: 0,
399            onkeydown: handle_keydown,
400            div {
401                class: "adui-tour-content",
402                style: "background: {panel_bg}; color: {panel_text}; border-radius: 8px; box-shadow: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px -4px rgba(0,0,0,0.12); max-width: 520px; min-width: 300px;",
403                onclick: move |evt| {
404                    evt.stop_propagation();
405                },
406                // Close button
407                if closable {
408                    button {
409                        class: "adui-tour-close",
410                        style: "position: absolute; top: 8px; right: 8px; border: none; background: none; cursor: pointer; font-size: 16px; color: {panel_text}; opacity: 0.65;",
411                        r#type: "button",
412                        onclick: move |_| handle_close(),
413                        "×"
414                    }
415                }
416                // Cover image
417                if let Some(cover) = step.cover {
418                    div {
419                        class: "adui-tour-cover",
420                        style: "padding: 16px 16px 0;",
421                        {cover}
422                    }
423                }
424                // Header with title
425                div {
426                    class: "adui-tour-header",
427                    style: "padding: 16px 16px 8px;",
428                    if let Some(title) = step.title {
429                        div {
430                            class: "adui-tour-title",
431                            style: "font-weight: 600; font-size: 16px;",
432                            "{title}"
433                        }
434                    }
435                }
436                // Description
437                if let Some(desc) = step.description {
438                    div {
439                        class: "adui-tour-description",
440                        style: "padding: 0 16px 16px; font-size: 14px; line-height: 1.5;",
441                        {desc}
442                    }
443                }
444                // Footer with indicators and buttons
445                div {
446                    class: "adui-tour-footer",
447                    style: "display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-top: 1px solid rgba(128,128,128,0.2);",
448                    // Indicators
449                    if show_indicators && total_steps > 1 {
450                        div {
451                            class: "adui-tour-indicators",
452                            style: "display: flex; gap: 4px;",
453                            {(0..total_steps).map(|idx| {
454                                let is_active = idx == current_step;
455                                let indicator_bg = if is_active {
456                                    match r#type {
457                                        TourType::Default => tokens.color_primary.clone(),
458                                        TourType::Primary => "#ffffff".to_string(),
459                                    }
460                                } else {
461                                    "rgba(128,128,128,0.3)".to_string()
462                                };
463                                rsx! {
464                                    span {
465                                        key: "indicator-{idx}",
466                                        class: "adui-tour-indicator",
467                                        style: "width: 6px; height: 6px; border-radius: 50%; background: {indicator_bg}; transition: background 0.2s;",
468                                    }
469                                }
470                            })}
471                        }
472                    } else {
473                        div { class: "adui-tour-indicators-placeholder" }
474                    }
475                    // Action buttons
476                    div {
477                        class: "adui-tour-actions",
478                        style: "display: flex; gap: 8px;",
479                        if !is_first {
480                            Button {
481                                variant: Some(ButtonVariant::Outlined),
482                                color: if r#type == TourType::Primary { Some(ButtonColor::Default) } else { None },
483                                onclick: move |_| handle_prev(),
484                                "{prev_text}"
485                            }
486                        }
487                        Button {
488                            variant: Some(ButtonVariant::Solid),
489                            color: if r#type == TourType::Primary { Some(ButtonColor::Default) } else { Some(ButtonColor::Primary) },
490                            onclick: move |_| handle_next(),
491                            if is_last { "{finish_text}" } else { "{next_text}" }
492                        }
493                    }
494                }
495            }
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn tour_step_builder_works() {
506        let step = TourStep::new("s1", "Title", "Description")
507            .target("#my-element")
508            .placement(TooltipPlacement::Top);
509
510        assert_eq!(step.key, "s1");
511        assert_eq!(step.title, Some("Title".to_string()));
512        assert_eq!(step.target, Some("#my-element".to_string()));
513        assert_eq!(step.placement, Some(TooltipPlacement::Top));
514    }
515
516    #[test]
517    fn tour_step_with_all_options() {
518        let step = TourStep::new("s2", "Step 2", "Description")
519            .target("#target")
520            .placement(TooltipPlacement::Bottom)
521            .next_button("Continue")
522            .prev_button("Back");
523
524        assert_eq!(step.key, "s2");
525        assert_eq!(step.title, Some("Step 2".to_string()));
526        assert_eq!(step.target, Some("#target".to_string()));
527        assert_eq!(step.placement, Some(TooltipPlacement::Bottom));
528        assert_eq!(step.next_button_text, Some("Continue".to_string()));
529        assert_eq!(step.prev_button_text, Some("Back".to_string()));
530    }
531
532    #[test]
533    fn tour_step_minimal() {
534        let step = TourStep::new("s3", "Title", "Description");
535        assert_eq!(step.key, "s3");
536        assert_eq!(step.title, Some("Title".to_string()));
537        assert!(step.target.is_none());
538        assert!(step.placement.is_none());
539    }
540
541    #[test]
542    fn tour_step_clone() {
543        let step1 = TourStep::new("s1", "Title", "Description")
544            .target("#target")
545            .placement(TooltipPlacement::Top);
546        let step2 = step1.clone();
547        assert_eq!(step1.key, step2.key);
548        assert_eq!(step1.title, step2.title);
549        assert_eq!(step1.target, step2.target);
550        assert_eq!(step1.placement, step2.placement);
551    }
552
553    #[test]
554    fn tour_type_class_names() {
555        assert_eq!(TourType::Default.as_class(), "adui-tour-default");
556        assert_eq!(TourType::Primary.as_class(), "adui-tour-primary");
557    }
558
559    #[test]
560    fn tour_type_default() {
561        assert_eq!(TourType::default(), TourType::Default);
562    }
563
564    #[test]
565    fn tour_type_equality() {
566        assert_eq!(TourType::Default, TourType::Default);
567        assert_eq!(TourType::Primary, TourType::Primary);
568        assert_ne!(TourType::Default, TourType::Primary);
569    }
570
571    #[test]
572    fn tour_type_clone() {
573        let t1 = TourType::Primary;
574        let t2 = t1;
575        assert_eq!(t1, t2);
576    }
577}