1use dioxus::prelude::*;
2
3#[derive(Clone, PartialEq)]
5pub struct CarouselSlide {
6 pub src: String,
8 pub alt: String,
10 pub caption_title: Option<String>,
12 pub caption_text: Option<String>,
14}
15
16#[derive(Clone, PartialEq, Props)]
51pub struct CarouselProps {
52 pub active: Signal<usize>,
54 pub slides: Vec<CarouselSlide>,
56 #[props(default = true)]
58 pub indicators: bool,
59 #[props(default = true)]
61 pub controls: bool,
62 #[props(default)]
64 pub fade: bool,
65 #[props(default)]
67 pub dark: bool,
68 #[props(default)]
70 pub ride: bool,
71 #[props(default = 5000)]
73 pub interval: u64,
74 #[props(default)]
76 pub class: String,
77}
78
79#[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 let mut transitioning = use_signal(|| Option::<(usize, usize, SlideDirection)>::None);
98 let trans = *transitioning.read();
99
100 let mut paused = use_signal(|| false);
102
103 let mut touch_start_x = use_signal(|| 0.0f64);
105
106 let mut go_direction = move |direction: SlideDirection| {
108 if transitioning.read().is_some() {
110 return; }
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 spawn(async move {
132 gloo_timers::future::TimeoutFuture::new(600).await;
133 active_signal.set(to);
134 transitioning.set(None);
135 });
136 };
137
138 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 onmouseenter: move |_| paused.set(true),
171 onmouseleave: move |_| paused.set(false),
172 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 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 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 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 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 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 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
271fn 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
316fn 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 assert_eq!(build_slide_style(0, 0, trans, true), "");
396 }
397}