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