Skip to main content

dioxus_ui_system/organisms/
carousel.rs

1//! Carousel organism component
2//!
3//! An image/content slider with navigation controls, touch swipe support,
4//! and autoplay functionality.
5
6use dioxus::prelude::*;
7use std::time::Duration;
8
9use crate::styles::Style;
10use crate::theme::{use_style, use_theme};
11
12/// Carousel orientation
13#[derive(Clone, PartialEq, Default, Debug)]
14pub enum Orientation {
15    /// Horizontal carousel (default)
16    #[default]
17    Horizontal,
18    /// Vertical carousel
19    Vertical,
20}
21
22/// Carousel options for configuration
23#[derive(Clone, PartialEq, Debug)]
24pub struct CarouselOptions {
25    /// Whether to loop around when reaching ends
26    pub r#loop: bool,
27    /// Autoplay interval in milliseconds (None to disable)
28    pub autoplay_ms: Option<u64>,
29    /// Start at specific index
30    pub start_index: usize,
31    /// Pause autoplay on hover
32    pub pause_on_hover: bool,
33}
34
35impl Default for CarouselOptions {
36    fn default() -> Self {
37        Self {
38            r#loop: true,
39            autoplay_ms: None,
40            start_index: 0,
41            pause_on_hover: true,
42        }
43    }
44}
45
46impl CarouselOptions {
47    /// Create new default options
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Set loop option
53    pub fn with_loop(mut self, r#loop: bool) -> Self {
54        self.r#loop = r#loop;
55        self
56    }
57
58    /// Set autoplay interval in milliseconds
59    pub fn with_autoplay_ms(mut self, interval_ms: u64) -> Self {
60        self.autoplay_ms = Some(interval_ms);
61        self
62    }
63
64    /// Set autoplay interval
65    pub fn with_autoplay(mut self, interval: Duration) -> Self {
66        self.autoplay_ms = Some(interval.as_millis() as u64);
67        self
68    }
69
70    /// Set start index
71    pub fn with_start_index(mut self, index: usize) -> Self {
72        self.start_index = index;
73        self
74    }
75
76    /// Set pause on hover
77    pub fn with_pause_on_hover(mut self, pause: bool) -> Self {
78        self.pause_on_hover = pause;
79        self
80    }
81}
82
83/// Shared carousel context for compound components
84#[derive(Clone)]
85pub struct CarouselContext {
86    /// Current active index signal
87    pub current_index: Signal<usize>,
88    /// Total number of items signal
89    pub total_items: Signal<usize>,
90    /// Carousel options
91    pub options: CarouselOptions,
92    /// Orientation
93    pub orientation: Orientation,
94    /// Go to next slide callback
95    pub go_next: Callback<()>,
96    /// Go to previous slide callback
97    pub go_prev: Callback<()>,
98    /// Go to specific index callback
99    pub go_to: Callback<usize>,
100    /// Whether currently at first slide (memoized)
101    pub can_go_prev: Memo<bool>,
102    /// Whether currently at last slide (memoized)
103    pub can_go_next: Memo<bool>,
104    /// Whether autoplay is paused
105    pub is_paused: Signal<bool>,
106}
107
108/// Hook to access carousel context
109pub fn use_carousel() -> Option<CarouselContext> {
110    try_use_context::<CarouselContext>()
111}
112
113/// Carousel container properties
114#[derive(Props, Clone, PartialEq)]
115pub struct CarouselProps {
116    /// Child elements (should include CarouselContent and controls)
117    pub children: Element,
118    /// Carousel configuration options
119    #[props(default)]
120    pub opts: CarouselOptions,
121    /// Orientation of the carousel
122    #[props(default)]
123    pub orientation: Orientation,
124    /// Callback when active index changes
125    #[props(default)]
126    pub on_index_change: Option<EventHandler<usize>>,
127    /// Custom inline styles
128    #[props(default)]
129    pub style: Option<String>,
130}
131
132/// Carousel container component
133///
134/// Provides context for child components and manages the carousel state.
135#[component]
136pub fn Carousel(props: CarouselProps) -> Element {
137    let opts = props.opts.clone();
138    let orientation = props.orientation.clone();
139
140    let mut current_index = use_signal(|| opts.start_index);
141    let total_items = use_signal(|| 0usize);
142    let mut is_paused = use_signal(|| false);
143
144    // Track if we can navigate
145    let can_go_prev: Memo<bool> = use_memo(move || {
146        let idx = current_index();
147        let total = total_items();
148        opts.r#loop || (idx > 0 && total > 0)
149    });
150
151    let can_go_next: Memo<bool> = use_memo(move || {
152        let idx = current_index();
153        let total = total_items();
154        opts.r#loop || (idx < total.saturating_sub(1) && total > 0)
155    });
156
157    // Navigation callbacks
158    let go_next = use_callback(move |()| {
159        let total = total_items();
160        if total == 0 {
161            return;
162        }
163        current_index.with_mut(|idx| {
164            if *idx < total - 1 {
165                *idx += 1;
166            } else if opts.r#loop {
167                *idx = 0;
168            }
169        });
170    });
171
172    let go_prev = use_callback(move |()| {
173        let total = total_items();
174        if total == 0 {
175            return;
176        }
177        current_index.with_mut(|idx| {
178            if *idx > 0 {
179                *idx -= 1;
180            } else if opts.r#loop {
181                *idx = total - 1;
182            }
183        });
184    });
185
186    let go_to = use_callback(move |index: usize| {
187        let total = total_items();
188        if index < total {
189            current_index.set(index);
190        }
191    });
192
193    // Handle index change callback
194    use_effect(move || {
195        let idx = current_index();
196        if let Some(on_change) = &props.on_index_change {
197            on_change.call(idx);
198        }
199    });
200
201    // Note: Autoplay timer implementation is platform-specific.
202    // Users can implement autoplay using the carousel context's go_next callback
203    // combined with their platform's timer (e.g., setInterval in JS, tokio::time on native).
204
205    let context = CarouselContext {
206        current_index,
207        total_items,
208        options: opts.clone(),
209        orientation: orientation.clone(),
210        go_next,
211        go_prev,
212        go_to,
213        can_go_prev,
214        can_go_next,
215        is_paused,
216    };
217
218    let container_style =
219        use_style(move |_t| Style::new().relative().w_full().overflow_hidden().build());
220
221    let handle_mouse_enter = move |_| {
222        if opts.pause_on_hover {
223            is_paused.set(true);
224        }
225    };
226
227    let handle_mouse_leave = move |_| {
228        if opts.pause_on_hover {
229            is_paused.set(false);
230        }
231    };
232
233    use_context_provider(|| context);
234
235    rsx! {
236        div {
237            role: "region",
238            aria_roledescription: "carousel",
239            style: "{container_style} {props.style.clone().unwrap_or_default()}",
240            onmouseenter: handle_mouse_enter,
241            onmouseleave: handle_mouse_leave,
242            {props.children}
243        }
244    }
245}
246
247/// Carousel content wrapper properties
248#[derive(Props, Clone, PartialEq)]
249pub struct CarouselContentProps {
250    /// Carousel items (should be CarouselItem components)
251    pub children: Element,
252    /// Custom inline styles
253    #[props(default)]
254    pub style: Option<String>,
255}
256
257/// Carousel content wrapper
258///
259/// Wraps the slides and handles the scrolling/transform.
260#[component]
261pub fn CarouselContent(props: CarouselContentProps) -> Element {
262    let carousel = use_carousel();
263
264    // Get current index for transform calculation
265    let current_idx = carousel.as_ref().map_or(0, |ctx| *ctx.current_index.read());
266
267    // Count children and update total_items
268    // This is done by rendering and letting CarouselItem update the count
269    let content_style = use_style(move |_t| {
270        Style::new()
271            .flex()
272            .transition("transform 500ms ease-in-out")
273            .transform(&format!("translateX(-{}%)", current_idx * 100))
274            .build()
275    });
276
277    rsx! {
278        div {
279            style: "{content_style} {props.style.clone().unwrap_or_default()}",
280            {props.children}
281        }
282    }
283}
284
285/// Carousel item properties
286#[derive(Props, Clone, PartialEq)]
287pub struct CarouselItemProps {
288    /// Item content
289    pub children: Element,
290    /// Item index (used for tracking position)
291    pub index: usize,
292    /// Custom inline styles
293    #[props(default)]
294    pub style: Option<String>,
295}
296
297/// Individual carousel slide/item
298#[component]
299pub fn CarouselItem(props: CarouselItemProps) -> Element {
300    let _theme = use_theme();
301    let carousel = use_carousel();
302    let index = props.index;
303
304    // Update total_items count when this item is mounted
305    let total_items_signal = carousel.as_ref().map(|ctx| ctx.total_items);
306    use_effect(move || {
307        if let Some(mut total_items) = total_items_signal {
308            total_items.with_mut(|count| {
309                if index >= *count {
310                    *count = index + 1;
311                }
312            });
313        }
314    });
315
316    let is_active = carousel
317        .as_ref()
318        .map_or(false, |ctx| *ctx.current_index.read() == props.index);
319
320    let item_style = use_style(move |_t| Style::new().min_w("100%").w_full().h_full().build());
321
322    rsx! {
323        div {
324            role: "group",
325            aria_roledescription: "slide",
326            aria_hidden: "{!is_active}",
327            style: "{item_style} {props.style.clone().unwrap_or_default()}",
328            {props.children}
329        }
330    }
331}
332
333/// Previous button properties
334#[derive(Props, Clone, PartialEq)]
335pub struct CarouselPreviousProps {
336    /// Custom inline styles
337    #[props(default)]
338    pub style: Option<String>,
339    /// Custom class
340    #[props(default)]
341    pub class: Option<String>,
342}
343
344/// Previous slide button
345#[component]
346pub fn CarouselPrevious(props: CarouselPreviousProps) -> Element {
347    let carousel = use_carousel();
348
349    let can_go_prev = carousel
350        .as_ref()
351        .map_or(false, |ctx| *ctx.can_go_prev.read());
352    let go_prev = carousel.as_ref().map(|ctx| ctx.go_prev.clone());
353
354    let button_style = use_style(move |t| {
355        Style::new()
356            .absolute()
357            .left("16px")
358            .top("50%")
359            .transform("translateY(-50%)")
360            .w_px(40)
361            .h_px(40)
362            .rounded_full()
363            .flex()
364            .items_center()
365            .justify_center()
366            .bg(&t.colors.background)
367            .border(1, &t.colors.border)
368            .cursor(if can_go_prev {
369                "pointer"
370            } else {
371                "not-allowed"
372            })
373            .opacity(if can_go_prev { 1.0 } else { 0.5 })
374            .shadow(&t.shadows.md)
375            .transition("all 150ms ease")
376            .z_index(10)
377            .build()
378    });
379
380    let handle_click = move |_| {
381        if let Some(ref cb) = go_prev {
382            if can_go_prev {
383                cb.call(());
384            }
385        }
386    };
387
388    rsx! {
389        button {
390            r#type: "button",
391            aria_label: "Previous slide",
392            style: "{button_style} {props.style.clone().unwrap_or_default()}",
393            class: props.class.clone().unwrap_or_default(),
394            disabled: !can_go_prev,
395            onclick: handle_click,
396            CarouselChevron { direction: ChevronDirection::Left }
397        }
398    }
399}
400
401/// Next button properties
402#[derive(Props, Clone, PartialEq)]
403pub struct CarouselNextProps {
404    /// Custom inline styles
405    #[props(default)]
406    pub style: Option<String>,
407    /// Custom class
408    #[props(default)]
409    pub class: Option<String>,
410}
411
412/// Next slide button
413#[component]
414pub fn CarouselNext(props: CarouselNextProps) -> Element {
415    let carousel = use_carousel();
416
417    let can_go_next = carousel
418        .as_ref()
419        .map_or(false, |ctx| *ctx.can_go_next.read());
420    let go_next = carousel.as_ref().map(|ctx| ctx.go_next.clone());
421
422    let button_style = use_style(move |t| {
423        Style::new()
424            .absolute()
425            .right("16px")
426            .top("50%")
427            .transform("translateY(-50%)")
428            .w_px(40)
429            .h_px(40)
430            .rounded_full()
431            .flex()
432            .items_center()
433            .justify_center()
434            .bg(&t.colors.background)
435            .border(1, &t.colors.border)
436            .cursor(if can_go_next {
437                "pointer"
438            } else {
439                "not-allowed"
440            })
441            .opacity(if can_go_next { 1.0 } else { 0.5 })
442            .shadow(&t.shadows.md)
443            .transition("all 150ms ease")
444            .z_index(10)
445            .build()
446    });
447
448    let handle_click = move |_| {
449        if let Some(ref cb) = go_next {
450            if can_go_next {
451                cb.call(());
452            }
453        }
454    };
455
456    rsx! {
457        button {
458            r#type: "button",
459            aria_label: "Next slide",
460            style: "{button_style} {props.style.clone().unwrap_or_default()}",
461            class: props.class.clone().unwrap_or_default(),
462            disabled: !can_go_next,
463            onclick: handle_click,
464            CarouselChevron { direction: ChevronDirection::Right }
465        }
466    }
467}
468
469/// Chevron direction for navigation buttons
470#[derive(Clone, PartialEq)]
471#[allow(dead_code)]
472enum ChevronDirection {
473    Left,
474    Right,
475    Up,
476    Down,
477}
478
479/// Chevron icon component
480#[derive(Props, Clone, PartialEq)]
481struct CarouselChevronProps {
482    direction: ChevronDirection,
483}
484
485#[component]
486fn CarouselChevron(props: CarouselChevronProps) -> Element {
487    let d = match props.direction {
488        ChevronDirection::Left => "M15 18l-6-6 6-6",
489        ChevronDirection::Right => "M9 18l6-6-6-6",
490        ChevronDirection::Up => "M18 15l-6-6-6 6",
491        ChevronDirection::Down => "M6 9l6 6 6-6",
492    };
493
494    let icon_style = use_style(|t| {
495        Style::new()
496            .w_px(20)
497            .h_px(20)
498            .text_color(&t.colors.foreground)
499            .build()
500    });
501
502    rsx! {
503        svg {
504            view_box: "0 0 24 24",
505            fill: "none",
506            stroke: "currentColor",
507            stroke_width: "2",
508            stroke_linecap: "round",
509            stroke_linejoin: "round",
510            style: "{icon_style}",
511            path { d: "{d}" }
512        }
513    }
514}
515
516/// Pagination dots properties
517#[derive(Props, Clone, PartialEq)]
518pub struct CarouselDotsProps {
519    /// Custom inline styles for container
520    #[props(default)]
521    pub style: Option<String>,
522    /// Custom inline styles for individual dots
523    #[props(default)]
524    pub dot_style: Option<String>,
525    /// Number of dots to show (if None, uses carousel item count)
526    #[props(default)]
527    pub count: Option<usize>,
528}
529
530/// Pagination dots indicator
531#[component]
532pub fn CarouselDots(props: CarouselDotsProps) -> Element {
533    let carousel = use_carousel();
534
535    let count = props
536        .count
537        .unwrap_or_else(|| carousel.as_ref().map_or(0, |ctx| *ctx.total_items.read()));
538
539    let current_index = carousel.as_ref().map_or(0, |ctx| *ctx.current_index.read());
540    let go_to = carousel.as_ref().map(|ctx| ctx.go_to.clone());
541
542    let container_style = use_style(move |_t| {
543        Style::new()
544            .absolute()
545            .bottom("16px")
546            .left("50%")
547            .transform("translateX(-50%)")
548            .flex()
549            .gap_px(8)
550            .z_index(10)
551            .build()
552    });
553
554    rsx! {
555        div {
556            role: "tablist",
557            aria_label: "Slide navigation",
558            style: "{container_style} {props.style.clone().unwrap_or_default()}",
559
560            for index in 0..count {
561                CarouselDot {
562                    index: index,
563                    is_active: index == current_index,
564                    go_to: go_to.clone(),
565                    style: props.dot_style.clone(),
566                }
567            }
568        }
569    }
570}
571
572/// Individual dot component
573#[derive(Props, Clone, PartialEq)]
574struct CarouselDotProps {
575    index: usize,
576    is_active: bool,
577    go_to: Option<Callback<usize>>,
578    style: Option<String>,
579}
580
581#[component]
582fn CarouselDot(props: CarouselDotProps) -> Element {
583    let is_active = props.is_active;
584    let index = props.index;
585    let go_to = props.go_to.clone();
586
587    let dot_style = use_style(move |t| {
588        Style::new()
589            .w_px(if is_active { 24 } else { 8 })
590            .h_px(8)
591            .rounded_full()
592            .bg(if is_active {
593                &t.colors.primary
594            } else {
595                &t.colors.muted
596            })
597            .cursor("pointer")
598            .transition("all 200ms ease")
599            .border(0, &t.colors.border)
600            .build()
601    });
602
603    let handle_click = move |_| {
604        if let Some(ref cb) = go_to {
605            cb.call(index);
606        }
607    };
608
609    rsx! {
610        button {
611            r#type: "button",
612            role: "tab",
613            aria_selected: "{is_active}",
614            aria_label: "Go to slide {index + 1}",
615            style: "{dot_style} {props.style.clone().unwrap_or_default()}",
616            onclick: handle_click,
617        }
618    }
619}
620
621/// Carousel with built-in content - simplified API
622#[derive(Props, Clone, PartialEq)]
623pub struct SimpleCarouselProps {
624    /// Slides content
625    pub items: Vec<Element>,
626    /// Carousel options
627    #[props(default)]
628    pub opts: CarouselOptions,
629    /// Orientation
630    #[props(default)]
631    pub orientation: Orientation,
632    /// Callback when index changes
633    #[props(default)]
634    pub on_index_change: Option<EventHandler<usize>>,
635    /// Show navigation arrows
636    #[props(default = true)]
637    pub show_arrows: bool,
638    /// Show pagination dots
639    #[props(default = true)]
640    pub show_dots: bool,
641    /// Custom inline styles
642    #[props(default)]
643    pub style: Option<String>,
644}
645
646/// Simple carousel with built-in controls
647#[component]
648pub fn SimpleCarousel(props: SimpleCarouselProps) -> Element {
649    let items_len = props.items.len();
650
651    rsx! {
652        Carousel {
653            opts: props.opts.clone(),
654            orientation: props.orientation.clone(),
655            on_index_change: props.on_index_change.clone(),
656
657            CarouselContent {
658                for (index, item) in props.items.iter().enumerate() {
659                    CarouselItem {
660                        key: "{index}",
661                        index: index,
662                        {item.clone()}
663                    }
664                }
665            }
666
667            if props.show_arrows {
668                CarouselPrevious {}
669                CarouselNext {}
670            }
671
672            if props.show_dots {
673                CarouselDots { count: items_len }
674            }
675        }
676    }
677}
678
679/// Touch-enabled carousel wrapper
680#[derive(Props, Clone, PartialEq)]
681pub struct TouchCarouselProps {
682    /// Child elements
683    pub children: Element,
684    /// Carousel options
685    #[props(default)]
686    pub opts: CarouselOptions,
687    /// Orientation
688    #[props(default)]
689    pub orientation: Orientation,
690    /// Callback when index changes
691    #[props(default)]
692    pub on_index_change: Option<EventHandler<usize>>,
693    /// Custom inline styles
694    #[props(default)]
695    pub style: Option<String>,
696}
697
698/// Touch-enabled carousel with swipe support
699///
700/// Note: Touch support is simplified. For full touch gesture support,
701/// implement platform-specific touch handling using the carousel context.
702#[component]
703pub fn TouchCarousel(props: TouchCarouselProps) -> Element {
704    // Simplified touch carousel that wraps the base Carousel
705    // Touch handling would require platform-specific implementation
706
707    rsx! {
708        Carousel {
709            opts: props.opts,
710            orientation: props.orientation,
711            on_index_change: props.on_index_change,
712            style: props.style,
713
714            {props.children}
715        }
716    }
717}