Skip to main content

dioxus_ui_system/atoms/
spinner.rs

1//! Spinner atom component
2//!
3//! Loading spinners and indicators.
4
5use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8/// CSS keyframes for spinner animations
9const SPINNER_STYLES: &str = r#"
10@keyframes spin {
11    from { transform: rotate(0deg); }
12    to { transform: rotate(360deg); }
13}
14
15@keyframes bounce {
16    0%, 100% { transform: translateY(0); }
17    50% { transform: translateY(-25%); }
18}
19
20@keyframes pulse {
21    0%, 100% { opacity: 1; transform: scale(1); }
22    50% { opacity: 0.5; transform: scale(0.9); }
23}
24
25@keyframes bars {
26    0%, 100% { transform: scaleY(0.3); }
27    50% { transform: scaleY(1); }
28}
29"#;
30
31pub fn default_loading_label() -> String {
32    "Loading".to_string()
33}
34
35/// Spinner variant
36#[derive(Default, Clone, PartialEq, Debug)]
37pub enum SpinnerVariant {
38    #[default]
39    Circular,
40    Dots,
41    Pulse,
42    Bars,
43}
44
45/// Spinner size
46#[derive(Default, Clone, PartialEq, Debug)]
47pub enum SpinnerSize {
48    Xs, // 12px
49    Sm, // 16px
50    #[default]
51    Md, // 24px
52    Lg, // 32px
53    Xl, // 48px
54}
55
56impl SpinnerSize {
57    fn to_px(&self) -> u8 {
58        match self {
59            SpinnerSize::Xs => 12,
60            SpinnerSize::Sm => 16,
61            SpinnerSize::Md => 24,
62            SpinnerSize::Lg => 32,
63            SpinnerSize::Xl => 48,
64        }
65    }
66}
67
68/// Spinner properties
69#[derive(Props, Clone, PartialEq)]
70pub struct SpinnerProps {
71    /// Spinner variant/style
72    #[props(default = SpinnerVariant::Circular)]
73    pub variant: SpinnerVariant,
74    /// Size variant
75    #[props(default = SpinnerSize::Md)]
76    pub size: SpinnerSize,
77    /// Color override
78    pub color: Option<String>,
79    /// Accessibility label
80    #[props(default = default_loading_label())]
81    pub label: String,
82    /// Speed multiplier (1.0 = default)
83    #[props(default = 1.0)]
84    pub speed: f32,
85    /// Additional CSS classes
86    #[props(default)]
87    pub class: Option<String>,
88}
89
90/// Spinner loading indicator component
91#[component]
92pub fn Spinner(props: SpinnerProps) -> Element {
93    let theme = use_theme();
94
95    let color = props
96        .color
97        .unwrap_or_else(|| theme.tokens.read().colors.primary.to_rgba());
98
99    let size = props.size.to_px();
100    let class_css = props
101        .class
102        .as_ref()
103        .map(|c| format!(" {}", c))
104        .unwrap_or_default();
105
106    let animation_duration = format!("{:.2}s", 1.0 / props.speed);
107
108    match props.variant {
109        SpinnerVariant::Circular => {
110            let stroke_width = size / 8;
111            let radius = (size - stroke_width) / 2;
112            let circumference = 2.0 * std::f32::consts::PI * radius as f32;
113
114            rsx! {
115                style { dangerous_inner_html: "{SPINNER_STYLES}" }
116
117                span {
118                    class: "spinner spinner-circular{class_css}",
119                    role: "status",
120                    aria_label: "{props.label}",
121
122                    svg {
123                        width: "{size}px",
124                        height: "{size}px",
125                        view_box: "0 0 {size} {size}",
126                        style: "animation: spin {animation_duration} linear infinite;",
127
128                        circle {
129                            cx: "{size / 2}",
130                            cy: "{size / 2}",
131                            r: "{radius}",
132                            fill: "none",
133                            stroke: "{color}",
134                            stroke_width: "{stroke_width}",
135                            stroke_linecap: "round",
136                            stroke_dasharray: "{circumference}",
137                            stroke_dashoffset: "{circumference * 0.75}",
138                        }
139                    }
140
141                    span {
142                        class: "sr-only",
143                        style: "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;",
144                        "{props.label}"
145                    }
146                }
147            }
148        }
149        SpinnerVariant::Dots => {
150            let dot_size = size / 4;
151            let gap = dot_size / 2;
152
153            rsx! {
154                style { dangerous_inner_html: "{SPINNER_STYLES}" }
155
156                span {
157                    class: "spinner spinner-dots{class_css}",
158                    role: "status",
159                    aria_label: "{props.label}",
160                    style: "display: inline-flex; align-items: center; gap: {gap}px;",
161
162                    for i in 0..3 {
163                        span {
164                            key: "{i}",
165                            style: format!("width: {dot_size}px; height: {dot_size}px; background: {color}; border-radius: 50%; animation: bounce {animation_duration} ease-in-out {:.2}s infinite;", i as f32 * 0.16),
166                        }
167                    }
168
169                    span {
170                        class: "sr-only",
171                        style: "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;",
172                        "{props.label}"
173                    }
174                }
175            }
176        }
177        SpinnerVariant::Pulse => {
178            rsx! {
179                style { dangerous_inner_html: "{SPINNER_STYLES}" }
180
181                span {
182                    class: "spinner spinner-pulse{class_css}",
183                    role: "status",
184                    aria_label: "{props.label}",
185
186                    span {
187                        style: "display: inline-block; width: {size}px; height: {size}px; background: {color}; border-radius: 50%; animation: pulse {animation_duration} ease-in-out infinite;",
188                    }
189
190                    span {
191                        class: "sr-only",
192                        style: "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;",
193                        "{props.label}"
194                    }
195                }
196            }
197        }
198        SpinnerVariant::Bars => {
199            let bar_width = size / 6;
200            let bar_height = size;
201
202            rsx! {
203                style { dangerous_inner_html: "{SPINNER_STYLES}" }
204
205                span {
206                    class: "spinner spinner-bars{class_css}",
207                    role: "status",
208                    aria_label: "{props.label}",
209                    style: "display: inline-flex; align-items: center; gap: 2px; height: {bar_height}px;",
210
211                    for i in 0..5 {
212                        span {
213                            key: "{i}",
214                            style: format!("width: {bar_width}px; height: 100%; background: {color}; animation: bars {animation_duration} ease-in-out {:.1}s infinite;", i as f32 * 0.1),
215                        }
216                    }
217
218                    span {
219                        class: "sr-only",
220                        style: "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;",
221                        "{props.label}"
222                    }
223                }
224            }
225        }
226    }
227}
228
229/// Loading overlay properties
230#[derive(Props, Clone, PartialEq)]
231pub struct LoadingOverlayProps {
232    /// Whether the overlay is visible
233    pub visible: bool,
234    /// Spinner size
235    #[props(default = SpinnerSize::Lg)]
236    pub spinner_size: SpinnerSize,
237    /// Message to display below spinner
238    #[props(default)]
239    pub message: Option<String>,
240    /// Background color (with opacity)
241    #[props(default)]
242    pub background: Option<String>,
243    /// Additional CSS classes
244    #[props(default)]
245    pub class: Option<String>,
246    /// Children elements (content that gets overlaid)
247    pub children: Element,
248}
249
250/// Loading overlay component
251#[component]
252pub fn LoadingOverlay(props: LoadingOverlayProps) -> Element {
253    let theme = use_theme();
254
255    let bg_color = props.background.unwrap_or_else(|| {
256        format!(
257            "{}80",
258            theme
259                .tokens
260                .read()
261                .colors
262                .background
263                .to_rgba()
264                .trim_start_matches('#')
265        )
266    });
267
268    let class_css = props
269        .class
270        .as_ref()
271        .map(|c| format!(" {}", c))
272        .unwrap_or_default();
273
274    rsx! {
275        div {
276            class: "loading-overlay-container{class_css}",
277            style: "position: relative;",
278
279            {props.children}
280
281            if props.visible {
282                div {
283                    class: "loading-overlay",
284                    style: "position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: {bg_color}; backdrop-filter: blur(2px); z-index: 50; transition: opacity 0.2s ease;",
285
286                    Spinner {
287                        size: props.spinner_size,
288                    }
289
290                    if let Some(message) = props.message {
291                        span {
292                            style: "margin-top: 16px; font-size: 14px; color: {theme.tokens.read().colors.foreground.to_rgba()};",
293                            "{message}"
294                        }
295                    }
296                }
297            }
298        }
299    }
300}