Skip to main content

dioxus_bootstrap_css/
carousel.rs

1use dioxus::prelude::*;
2
3/// A single slide in the Carousel.
4#[derive(Clone, PartialEq)]
5pub struct CarouselSlide {
6    /// Image source URL.
7    pub src: String,
8    /// Alt text for the image.
9    pub alt: String,
10    /// Optional caption title.
11    pub caption_title: Option<String>,
12    /// Optional caption text.
13    pub caption_text: Option<String>,
14}
15
16/// Bootstrap Carousel component — signal-driven, no JavaScript.
17///
18/// Supports slide/fade transitions, auto-play with configurable interval,
19/// pause on hover, keyboard navigation (arrow keys), and touch swipe.
20///
21/// ```rust
22/// let active = use_signal(|| 0usize);
23/// rsx! {
24///     Carousel {
25///         active: active,
26///         slides: vec![
27///             CarouselSlide { src: "/img/1.jpg".into(), alt: "First".into(),
28///                 caption_title: Some("First slide".into()), caption_text: None },
29///             CarouselSlide { src: "/img/2.jpg".into(), alt: "Second".into(),
30///                 caption_title: None, caption_text: None },
31///         ],
32///         ride: true,
33///         interval: 5000,
34///     }
35/// }
36/// ```
37#[derive(Clone, PartialEq, Props)]
38pub struct CarouselProps {
39    /// Signal controlling the active slide index.
40    pub active: Signal<usize>,
41    /// Slide definitions.
42    pub slides: Vec<CarouselSlide>,
43    /// Show indicator dots.
44    #[props(default = true)]
45    pub indicators: bool,
46    /// Show prev/next controls.
47    #[props(default = true)]
48    pub controls: bool,
49    /// Crossfade transition instead of slide.
50    #[props(default)]
51    pub fade: bool,
52    /// Dark variant for lighter background images.
53    #[props(default)]
54    pub dark: bool,
55    /// Enable auto-play cycling.
56    #[props(default)]
57    pub ride: bool,
58    /// Auto-play interval in milliseconds (default 5000).
59    #[props(default = 5000)]
60    pub interval: u64,
61    /// Additional CSS classes.
62    #[props(default)]
63    pub class: String,
64}
65
66/// Direction of slide transition.
67#[derive(Clone, Copy, PartialEq)]
68enum SlideDirection {
69    Next,
70    Prev,
71}
72
73#[component]
74pub fn Carousel(props: CarouselProps) -> Element {
75    let current = *props.active.read();
76    let mut active_signal = props.active;
77    let total = props.slides.len();
78
79    if total == 0 {
80        return rsx! {};
81    }
82
83    // Track which slide is transitioning and direction
84    let mut transitioning = use_signal(|| Option::<(usize, usize, SlideDirection)>::None);
85    let trans = *transitioning.read();
86
87    // Pause state for hover
88    let mut paused = use_signal(|| false);
89
90    // Touch tracking for swipe
91    let mut touch_start_x = use_signal(|| 0.0f64);
92
93    // Navigate to next/prev — reads signals fresh each call
94    let mut go_direction = move |direction: SlideDirection| {
95        // Read current state from signals (not stale captures)
96        if transitioning.read().is_some() {
97            return; // already transitioning
98        }
99        let cur = *active_signal.read();
100        let to = match direction {
101            SlideDirection::Next => {
102                if cur + 1 >= total {
103                    0
104                } else {
105                    cur + 1
106                }
107            }
108            SlideDirection::Prev => {
109                if cur == 0 {
110                    total - 1
111                } else {
112                    cur - 1
113                }
114            }
115        };
116        transitioning.set(Some((cur, to, direction)));
117        // After transition duration, finalize
118        spawn(async move {
119            gloo_timers::future::TimeoutFuture::new(600).await;
120            active_signal.set(to);
121            transitioning.set(None);
122        });
123    };
124
125    // Auto-play timer
126    let ride = props.ride;
127    let interval = props.interval;
128    use_future(move || async move {
129        if !ride || total <= 1 {
130            return;
131        }
132        loop {
133            gloo_timers::future::TimeoutFuture::new(interval as u32).await;
134            if !*paused.read() && transitioning.read().is_none() {
135                go_direction(SlideDirection::Next);
136            }
137        }
138    });
139
140    let mut classes = vec!["carousel".to_string(), "slide".to_string()];
141    if props.fade {
142        classes.push("carousel-fade".to_string());
143    }
144    if props.dark {
145        classes.push("carousel-dark".to_string());
146    }
147    if !props.class.is_empty() {
148        classes.push(props.class.clone());
149    }
150    let full_class = classes.join(" ");
151
152    rsx! {
153        div {
154            class: "{full_class}",
155            tabindex: "0",
156            // Pause on hover
157            onmouseenter: move |_| paused.set(true),
158            onmouseleave: move |_| paused.set(false),
159            // Keyboard navigation
160            onkeydown: move |evt: KeyboardEvent| {
161                match evt.key() {
162                    Key::ArrowLeft => go_direction(SlideDirection::Prev),
163                    Key::ArrowRight => go_direction(SlideDirection::Next),
164                    _ => {}
165                }
166            },
167            // Touch start
168            ontouchstart: move |evt: TouchEvent| {
169                if let Some(touch) = evt.touches().first() {
170                    let coords: dioxus_elements::geometry::PagePoint = touch.page_coordinates();
171                    touch_start_x.set(coords.x);
172                }
173            },
174            // Touch end — detect swipe direction
175            ontouchend: move |evt: TouchEvent| {
176                if let Some(touch) = evt.touches_changed().first() {
177                    let start = *touch_start_x.read();
178                    let coords: dioxus_elements::geometry::PagePoint = touch.page_coordinates();
179                    let diff = coords.x - start;
180                    // Minimum swipe threshold of 50px
181                    if diff < -50.0 {
182                        go_direction(SlideDirection::Next);
183                    } else if diff > 50.0 {
184                        go_direction(SlideDirection::Prev);
185                    }
186                }
187            },
188
189            // Indicators
190            if props.indicators {
191                div { class: "carousel-indicators",
192                    for i in 0..total {
193                        button {
194                            class: if current == i { "active" } else { "" },
195                            r#type: "button",
196                            "aria-current": if current == i { "true" } else { "false" },
197                            "aria-label": "Slide {i}",
198                            onclick: move |_| active_signal.set(i),
199                        }
200                    }
201                }
202            }
203
204            // Slides
205            div {
206                class: "carousel-inner",
207                style: "overflow: hidden;",
208                for (i, slide) in props.slides.iter().enumerate() {
209                    {
210                        let item_class = build_slide_class(i, current, trans, props.fade);
211                        let item_style = build_slide_style(i, current, trans, props.fade);
212                        rsx! {
213                            div {
214                                class: "{item_class}",
215                                style: "{item_style}",
216                                img {
217                                    class: "d-block w-100",
218                                    src: "{slide.src}",
219                                    alt: "{slide.alt}",
220                                }
221                                if slide.caption_title.is_some() || slide.caption_text.is_some() {
222                                    div { class: "carousel-caption d-none d-md-block",
223                                        if let Some(ref title) = slide.caption_title {
224                                            h5 { "{title}" }
225                                        }
226                                        if let Some(ref text) = slide.caption_text {
227                                            p { "{text}" }
228                                        }
229                                    }
230                                }
231                            }
232                        }
233                    }
234                }
235            }
236
237            // Controls
238            if props.controls && total > 1 {
239                button {
240                    class: "carousel-control-prev",
241                    r#type: "button",
242                    onclick: move |_| go_direction(SlideDirection::Prev),
243                    span { class: "carousel-control-prev-icon", "aria-hidden": "true" }
244                    span { class: "visually-hidden", "Previous" }
245                }
246                button {
247                    class: "carousel-control-next",
248                    r#type: "button",
249                    onclick: move |_| go_direction(SlideDirection::Next),
250                    span { class: "carousel-control-next-icon", "aria-hidden": "true" }
251                    span { class: "visually-hidden", "Next" }
252                }
253            }
254        }
255    }
256}
257
258/// Build the CSS class for a slide item during transitions.
259fn build_slide_class(
260    index: usize,
261    current: usize,
262    trans: Option<(usize, usize, SlideDirection)>,
263    fade: bool,
264) -> String {
265    match trans {
266        Some((from, to, direction)) => {
267            if fade {
268                if index == from {
269                    "carousel-item active".to_string()
270                } else if index == to {
271                    "carousel-item carousel-item-next carousel-item-start active".to_string()
272                } else {
273                    "carousel-item".to_string()
274                }
275            } else if index == from {
276                match direction {
277                    SlideDirection::Next => "carousel-item active carousel-item-start".to_string(),
278                    SlideDirection::Prev => "carousel-item active carousel-item-end".to_string(),
279                }
280            } else if index == to {
281                match direction {
282                    SlideDirection::Next => {
283                        "carousel-item carousel-item-next carousel-item-start".to_string()
284                    }
285                    SlideDirection::Prev => {
286                        "carousel-item carousel-item-prev carousel-item-end".to_string()
287                    }
288                }
289            } else {
290                "carousel-item".to_string()
291            }
292        }
293        None => {
294            if index == current {
295                "carousel-item active".to_string()
296            } else {
297                "carousel-item".to_string()
298            }
299        }
300    }
301}
302
303/// Build inline styles for slide positioning during transitions.
304fn build_slide_style(
305    index: usize,
306    _current: usize,
307    trans: Option<(usize, usize, SlideDirection)>,
308    fade: bool,
309) -> String {
310    if fade {
311        return String::new();
312    }
313    match trans {
314        Some((from, to, _direction)) => {
315            if index == from || index == to {
316                "transition: transform 0.6s ease-in-out;".to_string()
317            } else {
318                String::new()
319            }
320        }
321        None => String::new(),
322    }
323}