1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum TourType {
42 #[default]
44 Default,
45 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#[derive(Clone, PartialEq)]
60pub struct TourStep {
61 pub key: String,
63 pub title: Option<String>,
65 pub description: Option<Element>,
67 pub cover: Option<Element>,
69 pub placement: Option<TooltipPlacement>,
71 pub target: Option<String>,
74 pub next_button_text: Option<String>,
76 pub prev_button_text: Option<String>,
78}
79
80impl TourStep {
81 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 pub fn target(mut self, selector: impl Into<String>) -> Self {
102 self.target = Some(selector.into());
103 self
104 }
105
106 pub fn placement(mut self, placement: TooltipPlacement) -> Self {
108 self.placement = Some(placement);
109 self
110 }
111
112 pub fn cover(mut self, cover: Element) -> Self {
114 self.cover = Some(cover);
115 self
116 }
117
118 pub fn description_element(mut self, desc: Element) -> Self {
120 self.description = Some(desc);
121 self
122 }
123
124 pub fn next_button(mut self, text: impl Into<String>) -> Self {
126 self.next_button_text = Some(text.into());
127 self
128 }
129
130 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#[derive(Props, Clone, PartialEq)]
139pub struct TourProps {
140 pub open: bool,
142 pub steps: Vec<TourStep>,
144 #[props(optional)]
146 pub current: Option<usize>,
147 #[props(optional)]
149 pub on_close: Option<EventHandler<()>>,
150 #[props(optional)]
152 pub on_change: Option<EventHandler<usize>>,
153 #[props(optional)]
155 pub on_finish: Option<EventHandler<()>>,
156 #[props(default)]
158 pub r#type: TourType,
159 #[props(default = true)]
161 pub mask_closable: bool,
162 #[props(default = true)]
164 pub closable: bool,
165 #[props(default = true)]
167 pub show_indicators: bool,
168 #[props(optional)]
170 pub next_button_text: Option<String>,
171 #[props(optional)]
173 pub prev_button_text: Option<String>,
174 #[props(optional)]
176 pub finish_button_text: Option<String>,
177 #[props(optional)]
179 pub class: Option<String>,
180 #[props(optional)]
182 pub style: Option<String>,
183}
184
185#[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 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 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 {
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 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 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 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 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 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 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 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 if let Some(cover) = step.cover {
418 div {
419 class: "adui-tour-cover",
420 style: "padding: 16px 16px 0;",
421 {cover}
422 }
423 }
424 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 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 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 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 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}