1use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8const 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#[derive(Default, Clone, PartialEq, Debug)]
37pub enum SpinnerVariant {
38 #[default]
39 Circular,
40 Dots,
41 Pulse,
42 Bars,
43}
44
45#[derive(Default, Clone, PartialEq, Debug)]
47pub enum SpinnerSize {
48 Xs, Sm, #[default]
51 Md, Lg, Xl, }
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#[derive(Props, Clone, PartialEq)]
70pub struct SpinnerProps {
71 #[props(default = SpinnerVariant::Circular)]
73 pub variant: SpinnerVariant,
74 #[props(default = SpinnerSize::Md)]
76 pub size: SpinnerSize,
77 pub color: Option<String>,
79 #[props(default = default_loading_label())]
81 pub label: String,
82 #[props(default = 1.0)]
84 pub speed: f32,
85 #[props(default)]
87 pub class: Option<String>,
88}
89
90#[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#[derive(Props, Clone, PartialEq)]
231pub struct LoadingOverlayProps {
232 pub visible: bool,
234 #[props(default = SpinnerSize::Lg)]
236 pub spinner_size: SpinnerSize,
237 #[props(default)]
239 pub message: Option<String>,
240 #[props(default)]
242 pub background: Option<String>,
243 #[props(default)]
245 pub class: Option<String>,
246 pub children: Element,
248}
249
250#[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}