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