dioxus_ui_system/organisms/
carousel.rs1use dioxus::prelude::*;
7use std::time::Duration;
8
9use crate::styles::Style;
10use crate::theme::{use_style, use_theme};
11
12#[derive(Clone, PartialEq, Default, Debug)]
14pub enum Orientation {
15 #[default]
17 Horizontal,
18 Vertical,
20}
21
22#[derive(Clone, PartialEq, Debug)]
24pub struct CarouselOptions {
25 pub r#loop: bool,
27 pub autoplay_ms: Option<u64>,
29 pub start_index: usize,
31 pub pause_on_hover: bool,
33}
34
35impl Default for CarouselOptions {
36 fn default() -> Self {
37 Self {
38 r#loop: true,
39 autoplay_ms: None,
40 start_index: 0,
41 pause_on_hover: true,
42 }
43 }
44}
45
46impl CarouselOptions {
47 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn with_loop(mut self, r#loop: bool) -> Self {
54 self.r#loop = r#loop;
55 self
56 }
57
58 pub fn with_autoplay_ms(mut self, interval_ms: u64) -> Self {
60 self.autoplay_ms = Some(interval_ms);
61 self
62 }
63
64 pub fn with_autoplay(mut self, interval: Duration) -> Self {
66 self.autoplay_ms = Some(interval.as_millis() as u64);
67 self
68 }
69
70 pub fn with_start_index(mut self, index: usize) -> Self {
72 self.start_index = index;
73 self
74 }
75
76 pub fn with_pause_on_hover(mut self, pause: bool) -> Self {
78 self.pause_on_hover = pause;
79 self
80 }
81}
82
83#[derive(Clone)]
85pub struct CarouselContext {
86 pub current_index: Signal<usize>,
88 pub total_items: Signal<usize>,
90 pub options: CarouselOptions,
92 pub orientation: Orientation,
94 pub go_next: Callback<()>,
96 pub go_prev: Callback<()>,
98 pub go_to: Callback<usize>,
100 pub can_go_prev: Memo<bool>,
102 pub can_go_next: Memo<bool>,
104 pub is_paused: Signal<bool>,
106}
107
108pub fn use_carousel() -> Option<CarouselContext> {
110 try_use_context::<CarouselContext>()
111}
112
113#[derive(Props, Clone, PartialEq)]
115pub struct CarouselProps {
116 pub children: Element,
118 #[props(default)]
120 pub opts: CarouselOptions,
121 #[props(default)]
123 pub orientation: Orientation,
124 #[props(default)]
126 pub on_index_change: Option<EventHandler<usize>>,
127 #[props(default)]
129 pub style: Option<String>,
130}
131
132#[component]
136pub fn Carousel(props: CarouselProps) -> Element {
137 let opts = props.opts.clone();
138 let orientation = props.orientation.clone();
139
140 let mut current_index = use_signal(|| opts.start_index);
141 let total_items = use_signal(|| 0usize);
142 let mut is_paused = use_signal(|| false);
143
144 let can_go_prev: Memo<bool> = use_memo(move || {
146 let idx = current_index();
147 let total = total_items();
148 opts.r#loop || (idx > 0 && total > 0)
149 });
150
151 let can_go_next: Memo<bool> = use_memo(move || {
152 let idx = current_index();
153 let total = total_items();
154 opts.r#loop || (idx < total.saturating_sub(1) && total > 0)
155 });
156
157 let go_next = use_callback(move |()| {
159 let total = total_items();
160 if total == 0 {
161 return;
162 }
163 current_index.with_mut(|idx| {
164 if *idx < total - 1 {
165 *idx += 1;
166 } else if opts.r#loop {
167 *idx = 0;
168 }
169 });
170 });
171
172 let go_prev = use_callback(move |()| {
173 let total = total_items();
174 if total == 0 {
175 return;
176 }
177 current_index.with_mut(|idx| {
178 if *idx > 0 {
179 *idx -= 1;
180 } else if opts.r#loop {
181 *idx = total - 1;
182 }
183 });
184 });
185
186 let go_to = use_callback(move |index: usize| {
187 let total = total_items();
188 if index < total {
189 current_index.set(index);
190 }
191 });
192
193 use_effect(move || {
195 let idx = current_index();
196 if let Some(on_change) = &props.on_index_change {
197 on_change.call(idx);
198 }
199 });
200
201 let context = CarouselContext {
206 current_index,
207 total_items,
208 options: opts.clone(),
209 orientation: orientation.clone(),
210 go_next,
211 go_prev,
212 go_to,
213 can_go_prev,
214 can_go_next,
215 is_paused,
216 };
217
218 let container_style =
219 use_style(move |_t| Style::new().relative().w_full().overflow_hidden().build());
220
221 let handle_mouse_enter = move |_| {
222 if opts.pause_on_hover {
223 is_paused.set(true);
224 }
225 };
226
227 let handle_mouse_leave = move |_| {
228 if opts.pause_on_hover {
229 is_paused.set(false);
230 }
231 };
232
233 use_context_provider(|| context);
234
235 rsx! {
236 div {
237 role: "region",
238 aria_roledescription: "carousel",
239 style: "{container_style} {props.style.clone().unwrap_or_default()}",
240 onmouseenter: handle_mouse_enter,
241 onmouseleave: handle_mouse_leave,
242 {props.children}
243 }
244 }
245}
246
247#[derive(Props, Clone, PartialEq)]
249pub struct CarouselContentProps {
250 pub children: Element,
252 #[props(default)]
254 pub style: Option<String>,
255}
256
257#[component]
261pub fn CarouselContent(props: CarouselContentProps) -> Element {
262 let carousel = use_carousel();
263
264 let current_idx = carousel.as_ref().map_or(0, |ctx| *ctx.current_index.read());
266
267 let content_style = use_style(move |_t| {
270 Style::new()
271 .flex()
272 .transition("transform 500ms ease-in-out")
273 .transform(&format!("translateX(-{}%)", current_idx * 100))
274 .build()
275 });
276
277 rsx! {
278 div {
279 style: "{content_style} {props.style.clone().unwrap_or_default()}",
280 {props.children}
281 }
282 }
283}
284
285#[derive(Props, Clone, PartialEq)]
287pub struct CarouselItemProps {
288 pub children: Element,
290 pub index: usize,
292 #[props(default)]
294 pub style: Option<String>,
295}
296
297#[component]
299pub fn CarouselItem(props: CarouselItemProps) -> Element {
300 let _theme = use_theme();
301 let carousel = use_carousel();
302 let index = props.index;
303
304 let total_items_signal = carousel.as_ref().map(|ctx| ctx.total_items);
306 use_effect(move || {
307 if let Some(mut total_items) = total_items_signal {
308 total_items.with_mut(|count| {
309 if index >= *count {
310 *count = index + 1;
311 }
312 });
313 }
314 });
315
316 let is_active = carousel
317 .as_ref()
318 .map_or(false, |ctx| *ctx.current_index.read() == props.index);
319
320 let item_style = use_style(move |_t| Style::new().min_w("100%").w_full().h_full().build());
321
322 rsx! {
323 div {
324 role: "group",
325 aria_roledescription: "slide",
326 aria_hidden: "{!is_active}",
327 style: "{item_style} {props.style.clone().unwrap_or_default()}",
328 {props.children}
329 }
330 }
331}
332
333#[derive(Props, Clone, PartialEq)]
335pub struct CarouselPreviousProps {
336 #[props(default)]
338 pub style: Option<String>,
339 #[props(default)]
341 pub class: Option<String>,
342}
343
344#[component]
346pub fn CarouselPrevious(props: CarouselPreviousProps) -> Element {
347 let carousel = use_carousel();
348
349 let can_go_prev = carousel
350 .as_ref()
351 .map_or(false, |ctx| *ctx.can_go_prev.read());
352 let go_prev = carousel.as_ref().map(|ctx| ctx.go_prev.clone());
353
354 let button_style = use_style(move |t| {
355 Style::new()
356 .absolute()
357 .left("16px")
358 .top("50%")
359 .transform("translateY(-50%)")
360 .w_px(40)
361 .h_px(40)
362 .rounded_full()
363 .flex()
364 .items_center()
365 .justify_center()
366 .bg(&t.colors.background)
367 .border(1, &t.colors.border)
368 .cursor(if can_go_prev {
369 "pointer"
370 } else {
371 "not-allowed"
372 })
373 .opacity(if can_go_prev { 1.0 } else { 0.5 })
374 .shadow(&t.shadows.md)
375 .transition("all 150ms ease")
376 .z_index(10)
377 .build()
378 });
379
380 let handle_click = move |_| {
381 if let Some(ref cb) = go_prev {
382 if can_go_prev {
383 cb.call(());
384 }
385 }
386 };
387
388 rsx! {
389 button {
390 r#type: "button",
391 aria_label: "Previous slide",
392 style: "{button_style} {props.style.clone().unwrap_or_default()}",
393 class: props.class.clone().unwrap_or_default(),
394 disabled: !can_go_prev,
395 onclick: handle_click,
396 CarouselChevron { direction: ChevronDirection::Left }
397 }
398 }
399}
400
401#[derive(Props, Clone, PartialEq)]
403pub struct CarouselNextProps {
404 #[props(default)]
406 pub style: Option<String>,
407 #[props(default)]
409 pub class: Option<String>,
410}
411
412#[component]
414pub fn CarouselNext(props: CarouselNextProps) -> Element {
415 let carousel = use_carousel();
416
417 let can_go_next = carousel
418 .as_ref()
419 .map_or(false, |ctx| *ctx.can_go_next.read());
420 let go_next = carousel.as_ref().map(|ctx| ctx.go_next.clone());
421
422 let button_style = use_style(move |t| {
423 Style::new()
424 .absolute()
425 .right("16px")
426 .top("50%")
427 .transform("translateY(-50%)")
428 .w_px(40)
429 .h_px(40)
430 .rounded_full()
431 .flex()
432 .items_center()
433 .justify_center()
434 .bg(&t.colors.background)
435 .border(1, &t.colors.border)
436 .cursor(if can_go_next {
437 "pointer"
438 } else {
439 "not-allowed"
440 })
441 .opacity(if can_go_next { 1.0 } else { 0.5 })
442 .shadow(&t.shadows.md)
443 .transition("all 150ms ease")
444 .z_index(10)
445 .build()
446 });
447
448 let handle_click = move |_| {
449 if let Some(ref cb) = go_next {
450 if can_go_next {
451 cb.call(());
452 }
453 }
454 };
455
456 rsx! {
457 button {
458 r#type: "button",
459 aria_label: "Next slide",
460 style: "{button_style} {props.style.clone().unwrap_or_default()}",
461 class: props.class.clone().unwrap_or_default(),
462 disabled: !can_go_next,
463 onclick: handle_click,
464 CarouselChevron { direction: ChevronDirection::Right }
465 }
466 }
467}
468
469#[derive(Clone, PartialEq)]
471#[allow(dead_code)]
472enum ChevronDirection {
473 Left,
474 Right,
475 Up,
476 Down,
477}
478
479#[derive(Props, Clone, PartialEq)]
481struct CarouselChevronProps {
482 direction: ChevronDirection,
483}
484
485#[component]
486fn CarouselChevron(props: CarouselChevronProps) -> Element {
487 let d = match props.direction {
488 ChevronDirection::Left => "M15 18l-6-6 6-6",
489 ChevronDirection::Right => "M9 18l6-6-6-6",
490 ChevronDirection::Up => "M18 15l-6-6-6 6",
491 ChevronDirection::Down => "M6 9l6 6 6-6",
492 };
493
494 let icon_style = use_style(|t| {
495 Style::new()
496 .w_px(20)
497 .h_px(20)
498 .text_color(&t.colors.foreground)
499 .build()
500 });
501
502 rsx! {
503 svg {
504 view_box: "0 0 24 24",
505 fill: "none",
506 stroke: "currentColor",
507 stroke_width: "2",
508 stroke_linecap: "round",
509 stroke_linejoin: "round",
510 style: "{icon_style}",
511 path { d: "{d}" }
512 }
513 }
514}
515
516#[derive(Props, Clone, PartialEq)]
518pub struct CarouselDotsProps {
519 #[props(default)]
521 pub style: Option<String>,
522 #[props(default)]
524 pub dot_style: Option<String>,
525 #[props(default)]
527 pub count: Option<usize>,
528}
529
530#[component]
532pub fn CarouselDots(props: CarouselDotsProps) -> Element {
533 let carousel = use_carousel();
534
535 let count = props
536 .count
537 .unwrap_or_else(|| carousel.as_ref().map_or(0, |ctx| *ctx.total_items.read()));
538
539 let current_index = carousel.as_ref().map_or(0, |ctx| *ctx.current_index.read());
540 let go_to = carousel.as_ref().map(|ctx| ctx.go_to.clone());
541
542 let container_style = use_style(move |_t| {
543 Style::new()
544 .absolute()
545 .bottom("16px")
546 .left("50%")
547 .transform("translateX(-50%)")
548 .flex()
549 .gap_px(8)
550 .z_index(10)
551 .build()
552 });
553
554 rsx! {
555 div {
556 role: "tablist",
557 aria_label: "Slide navigation",
558 style: "{container_style} {props.style.clone().unwrap_or_default()}",
559
560 for index in 0..count {
561 CarouselDot {
562 index: index,
563 is_active: index == current_index,
564 go_to: go_to.clone(),
565 style: props.dot_style.clone(),
566 }
567 }
568 }
569 }
570}
571
572#[derive(Props, Clone, PartialEq)]
574struct CarouselDotProps {
575 index: usize,
576 is_active: bool,
577 go_to: Option<Callback<usize>>,
578 style: Option<String>,
579}
580
581#[component]
582fn CarouselDot(props: CarouselDotProps) -> Element {
583 let is_active = props.is_active;
584 let index = props.index;
585 let go_to = props.go_to.clone();
586
587 let dot_style = use_style(move |t| {
588 Style::new()
589 .w_px(if is_active { 24 } else { 8 })
590 .h_px(8)
591 .rounded_full()
592 .bg(if is_active {
593 &t.colors.primary
594 } else {
595 &t.colors.muted
596 })
597 .cursor("pointer")
598 .transition("all 200ms ease")
599 .border(0, &t.colors.border)
600 .build()
601 });
602
603 let handle_click = move |_| {
604 if let Some(ref cb) = go_to {
605 cb.call(index);
606 }
607 };
608
609 rsx! {
610 button {
611 r#type: "button",
612 role: "tab",
613 aria_selected: "{is_active}",
614 aria_label: "Go to slide {index + 1}",
615 style: "{dot_style} {props.style.clone().unwrap_or_default()}",
616 onclick: handle_click,
617 }
618 }
619}
620
621#[derive(Props, Clone, PartialEq)]
623pub struct SimpleCarouselProps {
624 pub items: Vec<Element>,
626 #[props(default)]
628 pub opts: CarouselOptions,
629 #[props(default)]
631 pub orientation: Orientation,
632 #[props(default)]
634 pub on_index_change: Option<EventHandler<usize>>,
635 #[props(default = true)]
637 pub show_arrows: bool,
638 #[props(default = true)]
640 pub show_dots: bool,
641 #[props(default)]
643 pub style: Option<String>,
644}
645
646#[component]
648pub fn SimpleCarousel(props: SimpleCarouselProps) -> Element {
649 let items_len = props.items.len();
650
651 rsx! {
652 Carousel {
653 opts: props.opts.clone(),
654 orientation: props.orientation.clone(),
655 on_index_change: props.on_index_change.clone(),
656
657 CarouselContent {
658 for (index, item) in props.items.iter().enumerate() {
659 CarouselItem {
660 key: "{index}",
661 index: index,
662 {item.clone()}
663 }
664 }
665 }
666
667 if props.show_arrows {
668 CarouselPrevious {}
669 CarouselNext {}
670 }
671
672 if props.show_dots {
673 CarouselDots { count: items_len }
674 }
675 }
676 }
677}
678
679#[derive(Props, Clone, PartialEq)]
681pub struct TouchCarouselProps {
682 pub children: Element,
684 #[props(default)]
686 pub opts: CarouselOptions,
687 #[props(default)]
689 pub orientation: Orientation,
690 #[props(default)]
692 pub on_index_change: Option<EventHandler<usize>>,
693 #[props(default)]
695 pub style: Option<String>,
696}
697
698#[component]
703pub fn TouchCarousel(props: TouchCarouselProps) -> Element {
704 rsx! {
708 Carousel {
709 opts: props.opts,
710 orientation: props.orientation,
711 on_index_change: props.on_index_change,
712 style: props.style,
713
714 {props.children}
715 }
716 }
717}