adui_dioxus/components/
carousel.rs

1//! Carousel component for cycling through images or content.
2//!
3//! A slideshow component for cycling through elements with support for
4//! various transition effects, autoplay, and navigation controls.
5
6use dioxus::prelude::*;
7
8/// Transition effect for the carousel.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum CarouselEffect {
11    /// Horizontal scrolling effect.
12    #[default]
13    ScrollX,
14    /// Fade in/out effect.
15    Fade,
16}
17
18impl CarouselEffect {
19    fn as_class(&self) -> &'static str {
20        match self {
21            CarouselEffect::ScrollX => "adui-carousel-scroll",
22            CarouselEffect::Fade => "adui-carousel-fade",
23        }
24    }
25}
26
27/// Position for the navigation dots.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum DotPlacement {
30    /// Dots at the top.
31    Top,
32    /// Dots at the bottom (default).
33    #[default]
34    Bottom,
35    /// Dots at the left.
36    Left,
37    /// Dots at the right.
38    Right,
39}
40
41impl DotPlacement {
42    fn as_class(&self) -> &'static str {
43        match self {
44            DotPlacement::Top => "adui-carousel-dots-top",
45            DotPlacement::Bottom => "adui-carousel-dots-bottom",
46            DotPlacement::Left => "adui-carousel-dots-left",
47            DotPlacement::Right => "adui-carousel-dots-right",
48        }
49    }
50
51    fn is_vertical(&self) -> bool {
52        matches!(self, DotPlacement::Left | DotPlacement::Right)
53    }
54}
55
56/// A single slide item for the Carousel.
57#[derive(Clone, Debug, PartialEq)]
58pub struct CarouselItem {
59    /// Content to display (can be HTML string or key for custom rendering).
60    pub content: String,
61    /// Optional background color.
62    pub background: Option<String>,
63}
64
65impl CarouselItem {
66    /// Create a new carousel item with content.
67    pub fn new(content: impl Into<String>) -> Self {
68        Self {
69            content: content.into(),
70            background: None,
71        }
72    }
73
74    /// Builder method to set background.
75    pub fn with_background(mut self, bg: impl Into<String>) -> Self {
76        self.background = Some(bg.into());
77        self
78    }
79}
80
81/// Props for the Carousel component.
82#[derive(Props, Clone, PartialEq)]
83pub struct CarouselProps {
84    /// Slide items to display.
85    #[props(default)]
86    pub items: Vec<CarouselItem>,
87    /// Number of slides (used when using children instead of items).
88    #[props(optional)]
89    pub slide_count: Option<usize>,
90    /// Transition effect type.
91    #[props(default)]
92    pub effect: CarouselEffect,
93    /// Whether to enable autoplay.
94    #[props(default)]
95    pub autoplay: bool,
96    /// Autoplay interval in milliseconds.
97    #[props(default = 3000)]
98    pub autoplay_speed: u64,
99    /// Whether to show navigation dots.
100    #[props(default = true)]
101    pub dots: bool,
102    /// Position of the navigation dots.
103    #[props(default)]
104    pub dot_placement: DotPlacement,
105    /// Whether to show arrow navigation.
106    #[props(default)]
107    pub arrows: bool,
108    /// Transition speed in milliseconds.
109    #[props(default = 500)]
110    pub speed: u32,
111    /// Initial slide index.
112    #[props(default)]
113    pub initial_slide: usize,
114    /// Enable infinite loop.
115    #[props(default = true)]
116    pub infinite: bool,
117    /// Pause autoplay on hover.
118    #[props(default = true)]
119    pub pause_on_hover: bool,
120    /// Callback when slide changes.
121    #[props(optional)]
122    pub on_change: Option<EventHandler<usize>>,
123    /// Callback before slide changes.
124    #[props(optional)]
125    pub before_change: Option<EventHandler<(usize, usize)>>,
126    /// Extra class for the root element.
127    #[props(optional)]
128    pub class: Option<String>,
129    /// Inline style for the root element.
130    #[props(optional)]
131    pub style: Option<String>,
132    /// Slide content (alternative to items prop, requires slide_count).
133    pub children: Element,
134}
135
136/// A carousel/slideshow component.
137#[component]
138pub fn Carousel(props: CarouselProps) -> Element {
139    let CarouselProps {
140        items,
141        slide_count,
142        effect,
143        autoplay: _autoplay,
144        autoplay_speed: _autoplay_speed,
145        dots,
146        dot_placement,
147        arrows,
148        speed,
149        initial_slide,
150        infinite,
151        pause_on_hover: _pause_on_hover,
152        on_change,
153        before_change,
154        class,
155        style,
156        children,
157    } = props;
158
159    // Current slide index
160    let mut current: Signal<usize> = use_signal(|| initial_slide);
161    // Hover state for pause on hover
162    let is_hovered: Signal<bool> = use_signal(|| false);
163
164    // Determine slide count from items or prop
165    let count = if !items.is_empty() {
166        items.len()
167    } else {
168        slide_count.unwrap_or(0)
169    };
170
171    // Navigation handlers
172    let go_to = {
173        let on_change = on_change.clone();
174        let before_change = before_change.clone();
175        move |index: usize| {
176            if count == 0 || index >= count {
177                return;
178            }
179            let curr = *current.read();
180            if curr == index {
181                return;
182            }
183            if let Some(handler) = &before_change {
184                handler.call((curr, index));
185            }
186            current.set(index);
187            if let Some(handler) = &on_change {
188                handler.call(index);
189            }
190        }
191    };
192
193    let go_prev = {
194        let on_change = on_change.clone();
195        let before_change = before_change.clone();
196        move |_: MouseEvent| {
197            if count == 0 {
198                return;
199            }
200            let curr = *current.read();
201            let prev = if curr == 0 {
202                if infinite {
203                    count - 1
204                } else {
205                    return;
206                }
207            } else {
208                curr - 1
209            };
210            if let Some(handler) = &before_change {
211                handler.call((curr, prev));
212            }
213            current.set(prev);
214            if let Some(handler) = &on_change {
215                handler.call(prev);
216            }
217        }
218    };
219
220    let go_next = {
221        let on_change = on_change.clone();
222        let before_change = before_change.clone();
223        move |_: MouseEvent| {
224            if count == 0 {
225                return;
226            }
227            let curr = *current.read();
228            let next = if curr + 1 >= count {
229                if infinite {
230                    0
231                } else {
232                    return;
233                }
234            } else {
235                curr + 1
236            };
237            if let Some(handler) = &before_change {
238                handler.call((curr, next));
239            }
240            current.set(next);
241            if let Some(handler) = &on_change {
242                handler.call(next);
243            }
244        }
245    };
246
247    // Build class list
248    let mut class_list = vec!["adui-carousel".to_string()];
249    class_list.push(effect.as_class().to_string());
250    class_list.push(dot_placement.as_class().to_string());
251    if dot_placement.is_vertical() {
252        class_list.push("adui-carousel-vertical".into());
253    }
254    if let Some(extra) = class {
255        class_list.push(extra);
256    }
257    let class_attr = class_list.join(" ");
258
259    let transition_style = format!("--adui-carousel-speed: {}ms;", speed);
260    let style_attr = format!("{}{}", transition_style, style.unwrap_or_default());
261
262    let current_index = *current.read();
263
264    // Hover handlers
265    let on_mouse_enter = {
266        let mut hovered = is_hovered;
267        move |_| hovered.set(true)
268    };
269    let on_mouse_leave = {
270        let mut hovered = is_hovered;
271        move |_| hovered.set(false)
272    };
273
274    // Track style for scrollX effect
275    let track_style = match effect {
276        CarouselEffect::ScrollX => format!("transform: translateX(-{}%);", current_index * 100),
277        CarouselEffect::Fade => String::new(),
278    };
279
280    rsx! {
281        div {
282            class: "{class_attr}",
283            style: "{style_attr}",
284            onmouseenter: on_mouse_enter,
285            onmouseleave: on_mouse_leave,
286
287            // Slides container
288            div { class: "adui-carousel-inner",
289                div {
290                    class: "adui-carousel-track",
291                    style: "{track_style}",
292                    // Render from items if provided
293                    for (i, item) in items.iter().enumerate() {
294                        {
295                            let is_active = i == current_index;
296                            let mut slide_class = vec!["adui-carousel-slide".to_string()];
297                            if is_active {
298                                slide_class.push("adui-carousel-slide-active".into());
299                            }
300                            let slide_style = item.background.as_ref()
301                                .map(|bg| format!("background: {};", bg))
302                                .unwrap_or_default();
303                            rsx! {
304                                div {
305                                    key: "{i}",
306                                    class: "{slide_class.join(\" \")}",
307                                    style: "{slide_style}",
308                                    "{item.content}"
309                                }
310                            }
311                        }
312                    }
313                    // If no items, render children (requires slide_count prop)
314                    if items.is_empty() {
315                        {children}
316                    }
317                }
318            }
319
320            // Arrow navigation
321            if arrows && count > 0 {
322                button {
323                    class: "adui-carousel-arrow adui-carousel-arrow-prev",
324                    r#type: "button",
325                    onclick: go_prev,
326                    "‹"
327                }
328                button {
329                    class: "adui-carousel-arrow adui-carousel-arrow-next",
330                    r#type: "button",
331                    onclick: go_next,
332                    "›"
333                }
334            }
335
336            // Dots navigation
337            if dots && count > 0 {
338                div { class: "adui-carousel-dots",
339                    for i in 0..count {
340                        button {
341                            key: "{i}",
342                            class: if i == current_index { "adui-carousel-dot adui-carousel-dot-active" } else { "adui-carousel-dot" },
343                            r#type: "button",
344                            onclick: {
345                                let mut go_to = go_to.clone();
346                                move |_| go_to(i)
347                            },
348                        }
349                    }
350                }
351            }
352        }
353    }
354}
355
356/// Props for a single carousel slide.
357#[derive(Props, Clone, PartialEq)]
358pub struct CarouselSlideProps {
359    /// Whether this slide is currently active (for fade effect).
360    #[props(default)]
361    pub active: bool,
362    /// Extra class for the slide.
363    #[props(optional)]
364    pub class: Option<String>,
365    /// Inline style for the slide.
366    #[props(optional)]
367    pub style: Option<String>,
368    /// Slide content.
369    pub children: Element,
370}
371
372/// A single slide in the carousel.
373#[component]
374pub fn CarouselSlide(props: CarouselSlideProps) -> Element {
375    let CarouselSlideProps {
376        active,
377        class,
378        style,
379        children,
380    } = props;
381
382    let mut class_list = vec!["adui-carousel-slide".to_string()];
383    if active {
384        class_list.push("adui-carousel-slide-active".into());
385    }
386    if let Some(extra) = class {
387        class_list.push(extra);
388    }
389    let class_attr = class_list.join(" ");
390    let style_attr = style.unwrap_or_default();
391
392    rsx! {
393        div { class: "{class_attr}", style: "{style_attr}",
394            {children}
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn carousel_effect_class_names() {
405        assert_eq!(CarouselEffect::ScrollX.as_class(), "adui-carousel-scroll");
406        assert_eq!(CarouselEffect::Fade.as_class(), "adui-carousel-fade");
407    }
408
409    #[test]
410    fn dot_placement_class_names() {
411        assert_eq!(DotPlacement::Top.as_class(), "adui-carousel-dots-top");
412        assert_eq!(DotPlacement::Bottom.as_class(), "adui-carousel-dots-bottom");
413        assert_eq!(DotPlacement::Left.as_class(), "adui-carousel-dots-left");
414        assert_eq!(DotPlacement::Right.as_class(), "adui-carousel-dots-right");
415    }
416
417    #[test]
418    fn dot_placement_vertical_detection() {
419        assert!(!DotPlacement::Top.is_vertical());
420        assert!(!DotPlacement::Bottom.is_vertical());
421        assert!(DotPlacement::Left.is_vertical());
422        assert!(DotPlacement::Right.is_vertical());
423    }
424
425    #[test]
426    fn carousel_item_builder() {
427        let item = CarouselItem::new("Hello").with_background("#ff0000");
428        assert_eq!(item.content, "Hello");
429        assert_eq!(item.background, Some("#ff0000".to_string()));
430    }
431}