1use crate::theme::{ThemeTokens, use_theme};
2use dioxus::prelude::*;
3use web_sys::window;
4
5#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum FloatButtonType {
8 Default,
9 #[default]
10 Primary,
11}
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum FloatButtonShape {
16 #[default]
17 Circle,
18 Square,
19}
20
21#[derive(Clone, Debug, Default, PartialEq)]
23pub struct BadgeConfig {
24 pub content: Option<String>,
25 pub class: Option<String>,
26 pub dot: bool,
27}
28
29impl BadgeConfig {
30 pub fn text(content: impl Into<String>) -> Self {
31 Self {
32 content: Some(content.into()),
33 ..Default::default()
34 }
35 }
36
37 pub fn dot() -> Self {
38 Self {
39 dot: true,
40 ..Default::default()
41 }
42 }
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46struct FloatButtonGroupContext {
47 shape: FloatButtonShape,
48 kind: FloatButtonType,
49}
50
51#[derive(Props, Clone, PartialEq)]
53pub struct FloatButtonGroupProps {
54 #[props(default)]
55 pub shape: FloatButtonShape,
56 #[props(default)]
57 pub r#type: FloatButtonType,
58 #[props(default = 12.0)]
59 pub gap: f32,
60 #[props(optional)]
61 pub right: Option<f32>,
62 #[props(optional)]
63 pub left: Option<f32>,
64 #[props(optional)]
65 pub top: Option<f32>,
66 #[props(optional)]
67 pub bottom: Option<f32>,
68 #[props(optional)]
69 pub z_index: Option<i32>,
70 #[props(default)]
71 pub pure: bool,
72 #[props(optional)]
73 pub class: Option<String>,
74 #[props(optional)]
75 pub style: Option<String>,
76 pub children: Element,
77}
78
79#[component]
81pub fn FloatButtonGroup(props: FloatButtonGroupProps) -> Element {
82 let FloatButtonGroupProps {
83 shape,
84 r#type,
85 gap,
86 right,
87 left,
88 top,
89 bottom,
90 z_index,
91 pure,
92 class,
93 style,
94 children,
95 } = props;
96 use_context_provider(|| FloatButtonGroupContext {
97 shape,
98 kind: r#type,
99 });
100
101 let mut class_list = vec!["adui-float-btn-group".to_string()];
102 if pure {
103 class_list.push("adui-float-btn-group-pure".into());
104 }
105 if let Some(extra) = class {
106 class_list.push(extra);
107 }
108 let class_attr = class_list.join(" ");
109
110 let placement = if pure {
111 String::new()
112 } else {
113 format!(
114 "{}{}{}{}{}",
115 right
116 .map(|v| format!("right:{v}px;"))
117 .unwrap_or_else(|| "right:24px;".into()),
118 left.map(|v| format!("left:{v}px;")).unwrap_or_default(),
119 top.map(|v| format!("top:{v}px;")).unwrap_or_default(),
120 bottom
121 .map(|v| format!("bottom:{v}px;"))
122 .unwrap_or_else(|| "bottom:72px;".into()),
123 z_index
124 .map(|z| format!("z-index:{z};"))
125 .unwrap_or_else(|| "z-index:99;".into()),
126 )
127 };
128
129 let style_attr = format!(
130 "--adui-fb-group-gap:{}px;{}{}",
131 gap,
132 placement,
133 style.unwrap_or_default()
134 );
135 rsx! {
136 div { class: "{class_attr}", style: "{style_attr}", {children} }
137 }
138}
139
140#[derive(Props, Clone, PartialEq)]
142pub struct FloatButtonPurePanelProps {
143 #[props(default)]
144 pub shape: FloatButtonShape,
145 #[props(default)]
146 pub r#type: FloatButtonType,
147 #[props(default = 12.0)]
148 pub gap: f32,
149 #[props(optional)]
150 pub class: Option<String>,
151 #[props(optional)]
152 pub style: Option<String>,
153 pub children: Element,
154}
155
156#[component]
157pub fn FloatButtonPurePanel(props: FloatButtonPurePanelProps) -> Element {
158 let FloatButtonPurePanelProps {
159 shape,
160 r#type,
161 gap,
162 class,
163 style,
164 children,
165 } = props;
166 rsx! {
167 FloatButtonGroup {
168 shape,
169 r#type,
170 gap,
171 pure: true,
172 class,
173 style,
174 right: None,
175 left: None,
176 top: None,
177 bottom: None,
178 z_index: None,
179 {children}
180 }
181 }
182}
183
184#[derive(Props, Clone, PartialEq)]
186pub struct FloatButtonProps {
187 #[props(default)]
188 pub r#type: FloatButtonType,
189 #[props(default)]
190 pub shape: FloatButtonShape,
191 #[props(default)]
192 pub danger: bool,
193 #[props(optional)]
194 pub href: Option<String>,
195 #[props(optional)]
196 pub icon: Option<Element>,
197 #[props(optional)]
198 pub description: Option<String>,
199 #[props(optional)]
200 pub content: Option<String>,
201 #[props(optional)]
202 pub badge: Option<BadgeConfig>,
203 #[props(optional)]
204 pub tooltip: Option<String>,
205 #[props(optional)]
206 pub class: Option<String>,
207 #[props(optional)]
208 pub class_names_root: Option<String>,
209 #[props(optional)]
210 pub class_names_icon: Option<String>,
211 #[props(optional)]
212 pub class_names_content: Option<String>,
213 #[props(optional)]
214 pub styles_root: Option<String>,
215 #[props(optional)]
216 pub style: Option<String>,
217 #[props(optional)]
218 pub right: Option<f32>,
219 #[props(optional)]
220 pub left: Option<f32>,
221 #[props(optional)]
222 pub top: Option<f32>,
223 #[props(optional)]
224 pub bottom: Option<f32>,
225 #[props(optional)]
226 pub z_index: Option<i32>,
227 #[props(optional)]
228 pub onclick: Option<EventHandler<MouseEvent>>,
229}
230
231#[derive(Props, Clone, PartialEq)]
233pub struct BackTopProps {
234 #[props(default = FloatButtonType::Primary)]
235 pub r#type: FloatButtonType,
236 #[props(default)]
237 pub shape: FloatButtonShape,
238 #[props(default)]
239 pub danger: bool,
240 #[props(optional)]
241 pub tooltip: Option<String>,
242 #[props(optional)]
243 pub class: Option<String>,
244 #[props(optional)]
245 pub style: Option<String>,
246 #[props(optional)]
247 pub icon: Option<Element>,
248 #[props(optional)]
249 pub description: Option<String>,
250 #[props(optional)]
251 pub content: Option<String>,
252 #[props(optional)]
253 pub badge: Option<BadgeConfig>,
254 #[props(optional)]
255 pub right: Option<f32>,
256 #[props(optional)]
257 pub left: Option<f32>,
258 #[props(optional)]
259 pub top: Option<f32>,
260 #[props(optional)]
261 pub bottom: Option<f32>,
262 #[props(optional)]
263 pub z_index: Option<i32>,
264 #[props(optional)]
265 pub onclick: Option<EventHandler<MouseEvent>>,
266}
267
268#[component]
269pub fn BackTop(props: BackTopProps) -> Element {
270 let BackTopProps {
271 r#type,
272 shape,
273 danger,
274 tooltip,
275 class,
276 style,
277 icon,
278 description,
279 content,
280 badge,
281 right,
282 left,
283 top,
284 bottom,
285 z_index,
286 onclick,
287 } = props;
288 let default_icon = icon.unwrap_or_else(|| rsx!(span { "↑" }));
289 let handler = onclick;
290 rsx! {
291 FloatButton {
292 r#type,
293 shape,
294 danger,
295 tooltip: tooltip.clone(),
296 class: class.clone(),
297 style: style.clone(),
298 icon: Some(default_icon),
299 description,
300 content,
301 badge,
302 right,
303 left,
304 top,
305 bottom,
306 z_index,
307 onclick: move |evt: Event<MouseData>| {
308 if let Some(h) = handler.as_ref() {
309 h.call(evt.clone());
310 }
311 if let Some(win) = window() {
312 win.scroll_to_with_x_and_y(0.0, 0.0);
313 }
314 }
315 }
316 }
317}
318
319#[component]
321pub fn FloatButton(props: FloatButtonProps) -> Element {
322 let FloatButtonProps {
323 r#type,
324 shape,
325 danger,
326 href,
327 icon,
328 description,
329 content,
330 badge,
331 tooltip,
332 class,
333 class_names_root,
334 class_names_icon,
335 class_names_content,
336 styles_root,
337 style,
338 right,
339 left,
340 top,
341 bottom,
342 z_index,
343 onclick,
344 } = props;
345
346 let theme = use_theme();
347 let tokens = theme.tokens();
348 let group_ctx = try_use_context::<FloatButtonGroupContext>();
349 let is_grouped = group_ctx.is_some();
350 let (merged_shape, merged_type) = if let Some(ctx) = group_ctx {
351 (ctx.shape, ctx.kind)
352 } else {
353 (shape, r#type)
354 };
355 let visuals = visuals(&tokens, merged_type, danger);
356 let metrics = metrics(merged_shape);
357 let text_slot = content.clone().or(description.clone());
358 let has_content = text_slot.is_some();
359
360 let mut class_list = vec!["adui-float-btn".to_string()];
361 class_list.push(match merged_type {
362 FloatButtonType::Primary => "adui-float-btn-primary".into(),
363 FloatButtonType::Default => "adui-float-btn-default".into(),
364 });
365 class_list.push(match merged_shape {
366 FloatButtonShape::Circle => "adui-float-btn-circle".into(),
367 FloatButtonShape::Square => "adui-float-btn-square".into(),
368 });
369 if !is_grouped {
370 class_list.push("adui-float-btn-individual".into());
371 }
372 if !has_content {
373 class_list.push("adui-float-btn-icon-only".into());
374 }
375 if let Some(extra) = class.as_ref() {
376 class_list.push(extra.clone());
377 }
378 if let Some(extra) = class_names_root.as_ref() {
379 class_list.push(extra.clone());
380 }
381 let class_attr = class_list.join(" ");
382
383 let placement = if is_grouped {
384 String::new()
385 } else {
386 format!(
387 "{}{}{}{}{}",
388 right
389 .map(|v| format!("right:{v}px;"))
390 .unwrap_or_else(|| "right:24px;".into()),
391 left.map(|v| format!("left:{v}px;")).unwrap_or_default(),
392 top.map(|v| format!("top:{v}px;")).unwrap_or_default(),
393 bottom
394 .map(|v| format!("bottom:{v}px;"))
395 .unwrap_or_else(|| "bottom:72px;".into()),
396 z_index
397 .map(|z| format!("z-index:{z};"))
398 .unwrap_or_else(|| "z-index:99;".into()),
399 )
400 };
401
402 let style_attr = format!(
403 "--adui-fb-bg:{};--adui-fb-bg-hover:{};--adui-fb-bg-active:{};\
404 --adui-fb-color:{};--adui-fb-color-hover:{};--adui-fb-color-active:{};\
405 --adui-fb-border:{};--adui-fb-border-hover:{};--adui-fb-border-active:{};\
406 --adui-fb-radius:{}px;--adui-fb-shadow:{};\
407 --adui-fb-size:{}px;--adui-fb-padding-inline:{}px;\
408 {}{}{}",
409 visuals.bg,
410 visuals.bg_hover,
411 visuals.bg_active,
412 visuals.color,
413 visuals.color_hover,
414 visuals.color_active,
415 visuals.border,
416 visuals.border_hover,
417 visuals.border_active,
418 metrics.radius,
419 visuals.shadow,
420 metrics.size,
421 metrics.padding_inline,
422 placement,
423 styles_root.unwrap_or_default(),
424 style.unwrap_or_default()
425 );
426
427 let mut icon_class = "adui-float-btn-icon".to_string();
428 if let Some(extra) = class_names_icon.as_ref() {
429 icon_class.push(' ');
430 icon_class.push_str(extra);
431 }
432 let mut content_class = "adui-float-btn-content".to_string();
433 if let Some(extra) = class_names_content.as_ref() {
434 content_class.push(' ');
435 content_class.push_str(extra);
436 }
437
438 let badge_node = badge.map(|cfg| {
439 let BadgeConfig {
440 content,
441 class,
442 dot,
443 } = cfg;
444 let mut badge_class = "adui-float-btn-badge".to_string();
445 if dot {
446 badge_class.push_str(" adui-float-btn-badge-dot");
447 }
448 if let Some(extra) = class {
449 badge_class.push(' ');
450 badge_class.push_str(&extra);
451 }
452 rsx!(span { class: "{badge_class}",
453 if !dot {
454 if let Some(text) = content.clone() {
455 "{text}"
456 }
457 }
458 })
459 });
460
461 let contents = rsx! {
462 if let Some(icon_node) = icon {
463 span { class: "{icon_class}", {icon_node} }
464 }
465 if let Some(desc) = text_slot.clone() {
466 span { class: "{content_class}", "{desc}" }
467 }
468 if let Some(node) = badge_node {
469 {node}
470 }
471 };
472
473 let title_attr = tooltip.clone().unwrap_or_default();
474 let aria_label = if title_attr.is_empty() {
475 "float button".to_string()
476 } else {
477 title_attr.clone()
478 };
479
480 if let Some(href_val) = href {
481 let handler = onclick;
482 return rsx! {
483 a {
484 class: "{class_attr}",
485 style: "{style_attr}",
486 href: "{href_val}",
487 role: "button",
488 title: "{title_attr}",
489 "aria-label": "{aria_label}",
490 onclick: move |evt| {
491 if let Some(h) = handler.as_ref() {
492 h.call(evt);
493 }
494 },
495 {contents}
496 }
497 };
498 }
499
500 let handler = onclick;
501 rsx! {
502 button {
503 class: "{class_attr}",
504 style: "{style_attr}",
505 r#type: "button",
506 role: "button",
507 title: "{title_attr}",
508 "aria-label": "{aria_label}",
509 onclick: move |evt| {
510 if let Some(h) = handler.as_ref() {
511 h.call(evt);
512 }
513 },
514 {contents}
515 }
516 }
517}
518
519struct FloatVisuals {
520 bg: String,
521 bg_hover: String,
522 bg_active: String,
523 color: String,
524 color_hover: String,
525 color_active: String,
526 border: String,
527 border_hover: String,
528 border_active: String,
529 shadow: String,
530}
531
532struct FloatMetrics {
533 radius: f32,
534 size: f32,
535 padding_inline: f32,
536}
537
538fn metrics(shape: FloatButtonShape) -> FloatMetrics {
539 match shape {
540 FloatButtonShape::Circle => FloatMetrics {
541 radius: 28.0,
542 size: 56.0,
543 padding_inline: 0.0,
544 },
545 FloatButtonShape::Square => FloatMetrics {
546 radius: 16.0,
547 size: 56.0,
548 padding_inline: 12.0,
549 },
550 }
551}
552
553fn visuals(tokens: &ThemeTokens, kind: FloatButtonType, danger: bool) -> FloatVisuals {
554 let (accent, accent_hover, accent_active) = if danger {
555 (
556 tokens.color_error.clone(),
557 tokens.color_error_hover.clone(),
558 tokens.color_error_active.clone(),
559 )
560 } else {
561 (
562 tokens.color_primary.clone(),
563 tokens.color_primary_hover.clone(),
564 tokens.color_primary_active.clone(),
565 )
566 };
567
568 match kind {
569 FloatButtonType::Primary => FloatVisuals {
570 bg: accent.clone(),
571 bg_hover: accent_hover.clone(),
572 bg_active: accent_active.clone(),
573 color: "#ffffff".into(),
574 color_hover: "#ffffff".into(),
575 color_active: "#ffffff".into(),
576 border: accent.clone(),
577 border_hover: accent_hover.clone(),
578 border_active: accent_active.clone(),
579 shadow: "0 6px 16px rgba(0,0,0,0.2)".into(),
580 },
581 FloatButtonType::Default => FloatVisuals {
582 bg: tokens.color_bg_container.clone(),
583 bg_hover: tokens.color_bg_container.clone(),
584 bg_active: tokens.color_bg_container.clone(),
585 color: tokens.color_text.clone(),
586 color_hover: tokens.color_primary.clone(),
587 color_active: tokens.color_primary_active.clone(),
588 border: tokens.color_border.clone(),
589 border_hover: tokens.color_border_hover.clone(),
590 border_active: tokens.color_primary_active.clone(),
591 shadow: "0 6px 16px rgba(0,0,0,0.12)".into(),
592 },
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::theme::ThemeTokens;
600
601 #[test]
602 fn metrics_reflect_shape_rules() {
603 let circle = metrics(FloatButtonShape::Circle);
604 assert_eq!(circle.padding_inline, 0.0);
605 assert_eq!(circle.size, 56.0);
606 assert!((circle.radius - 28.0).abs() < f32::EPSILON);
607
608 let square = metrics(FloatButtonShape::Square);
609 assert_eq!(square.padding_inline, 12.0);
610 assert!(square.radius < circle.radius);
611 }
612
613 #[test]
614 fn visuals_switch_between_danger_and_default() {
615 let tokens = ThemeTokens::light();
616 let primary = visuals(&tokens, FloatButtonType::Primary, false);
617 assert_eq!(primary.bg, tokens.color_primary);
618 assert_eq!(primary.color, "#ffffff");
619
620 let danger = visuals(&tokens, FloatButtonType::Primary, true);
621 assert_eq!(danger.bg, tokens.color_error);
622 assert_eq!(danger.border, tokens.color_error);
623
624 let default_style = visuals(&tokens, FloatButtonType::Default, false);
625 assert_eq!(default_style.bg, tokens.color_bg_container);
626 assert_eq!(default_style.color, tokens.color_text);
627 }
628}