Skip to main content

fc_dioxus/components/
carousel.rs

1//! A scrolling row of slides with paging buttons, from carousel.css. A port of
2//! upstream's `carousel.ts`.
3use crate::{
4    core::{
5        assets,
6        attrs::{carousel_control_attribute, component_attributes},
7        platform::DomBridge,
8    },
9    prelude::*,
10    theme::use_stylesheets,
11};
12const BEM: Bem = Bem::new("fc-carousel");
13const BTN: Bem = Bem::new("fc-btn");
14fc_modifier! {
15    /// Carousel layouts.
16    pub enum CarouselLayout for BEM {
17        /// Slides snap, several to a view.
18        Row => None,
19        /// One large slide at a time.
20        Hero => "hero",
21        /// Free scrolling without snapping.
22        Free => "free",
23    }
24}
25
26/// How much of the visible width one press of a paging button moves: most of it, so the
27/// last visible slide becomes the first and the reader keeps their place.
28pub const PAGE: f64 = 0.85;
29
30/// Whether a track is at its start and at its end, from upstream's `edges`. The offset
31/// is taken as an absolute value because `scrollLeft` runs negative in RTL.
32///
33/// ```
34/// use fc_dioxus::components::carousel::edges;
35/// assert_eq!(edges(0.0, 1000.0, 400.0), (true, false));
36/// assert_eq!(edges(-600.0, 1000.0, 400.0), (false, true));
37/// assert_eq!(edges(0.0, 400.0, 400.0), (true, true));
38/// ```
39#[must_use]
40pub fn edges(offset: f64, scroll_width: f64, client_width: f64) -> (bool, bool) {
41    let offset = offset.abs();
42    let max = scroll_width - client_width;
43    (offset <= 1.0, offset >= max - 1.0)
44}
45
46/// A named region of slides that scrolls by touch, trackpad, and keyboard with no script;
47/// the previous and next buttons page it for everyone else and are `aria-disabled` at
48/// their end.
49#[component]
50pub fn Carousel(
51    label: AccessibleName,
52    #[props(default)] layout: CarouselLayout,
53    #[props(default)] class: Option<String>,
54    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
55    /// [`Slide`]s.
56    children: Element,
57) -> Element {
58    let id = use_id();
59    // At the start before anything scrolls; whether there is an end is measured.
60    let mut at = use_signal(|| (true, None::<bool>));
61    let attributes = component_attributes(
62        attributes,
63        ClassList::new(BEM.block())
64            .with_modifier(BEM, layout)
65            .extend(class.as_deref()),
66        &["aria-roledescription", "aria-label"],
67    );
68    let track_id = id.clone();
69    let sync = move || {
70        let id = track_id.clone();
71        async move {
72            if let Ok((start, end)) = DomBridge.scroll_edges(&id).await {
73                at.set((start, Some(end)));
74            }
75        }
76    };
77    let (start, end) = at();
78    let label_text = label.to_string();
79    rsx! {
80        if use_stylesheets() {
81            document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
82            document::Stylesheet { href: assets::COMPONENTS_CAROUSEL_CSS }
83        }
84        section { aria_roledescription: "carousel", aria_label: "{label_text}", ..attributes,
85            div { class: BEM.element("controls"),
86                button { class: control_class(), r#type: "button", aria_label: "Previous",
87                    aria_disabled: start.then_some("true"),
88                    onclick: { let id = id.clone(); move |_| { let id = id.clone(); async move { if !start { let _ = DomBridge.page(&id, false).await; } } } },
89                    ..crate::spread![carousel_control_attribute(false)], "‹"
90                }
91                button { class: control_class(), r#type: "button", aria_label: "Next",
92                    aria_disabled: end.map(|end| end.to_string()),
93                    onclick: { let id = id.clone(); move |_| { let id = id.clone(); async move { if end != Some(true) { let _ = DomBridge.page(&id, true).await; } } } },
94                    ..crate::spread![carousel_control_attribute(true)], "›"
95                }
96            }
97            ul { class: BEM.element("track"), id: "{id}", tabindex: "0", aria_label: "{label_text}",
98                onmounted: { let sync = sync.clone(); move |_| sync() },
99                // Stylesheets and images change whether there is anywhere to scroll at all.
100                onresize: { let sync = sync.clone(); move |_| sync() },
101                onscroll: move |_| sync(),
102                {children}
103            }
104        }
105    }
106}
107
108fn control_class() -> String {
109    ClassList::new(BTN.block())
110        .with(BTN.modifier_raw("secondary"))
111        .with(BTN.modifier_raw("icon"))
112        .with(BTN.modifier_raw("sm"))
113        .to_string()
114}
115
116/// One slide, announced as "n of total".
117#[component]
118pub fn Slide(
119    index: u32,
120    total: u32,
121    #[props(default)] caption: Option<Element>,
122    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
123    children: Element,
124) -> Element {
125    rsx! {
126        li { class: BEM.element("item"), aria_roledescription: "slide", aria_label: "{index} of {total}", ..attributes,
127            {children}
128            if let Some(caption) = caption { p { class: BEM.element("caption"), {caption} } }
129        }
130    }
131}