adui_dioxus/components/
float_button.rs

1use crate::theme::{ThemeTokens, use_theme};
2use dioxus::prelude::*;
3use web_sys::window;
4
5/// Visual style for the floating action button.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum FloatButtonType {
8    Default,
9    #[default]
10    Primary,
11}
12
13/// Shape of the floating action button.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum FloatButtonShape {
16    #[default]
17    Circle,
18    Square,
19}
20
21/// Simplified badge config (subset of antd badge).
22#[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/// Group container for multiple float buttons.
52#[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/// Wrap float buttons together (minimal vertical stack).
80#[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/// Static panel version of float buttons, matching AntD `FloatButton.PurePanel`.
141#[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/// Floating action button props.
185#[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/// BackTop helper: scrolls window to top and renders a FloatButton.
232#[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/// Floating action button with Ant Design flavored theming.
320#[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}