adui_dioxus/components/
button.rs

1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::theme::{ThemeTokens, use_theme};
3use dioxus::prelude::*;
4
5/// Supported button visual types(兼容旧 API).
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum ButtonType {
8    #[default]
9    Default,
10    Primary,
11    Dashed,
12    Text,
13    Link,
14}
15
16/// Button tone(新 API,向后兼容 danger 开关)。
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum ButtonColor {
19    #[default]
20    Default,
21    Primary,
22    Success,
23    Warning,
24    Danger,
25}
26
27/// Button variant(新 API)。
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum ButtonVariant {
30    Solid,
31    #[default]
32    Outlined,
33    Dashed,
34    Text,
35    Link,
36}
37
38/// Button size variants.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum ButtonSize {
41    Small,
42    #[default]
43    Middle,
44    Large,
45}
46
47impl ButtonSize {
48    fn from_global(size: ComponentSize) -> Self {
49        match size {
50            ComponentSize::Small => ButtonSize::Small,
51            ComponentSize::Large => ButtonSize::Large,
52            ComponentSize::Middle => ButtonSize::Middle,
53        }
54    }
55}
56
57/// Shape variants for the button outline.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum ButtonShape {
60    #[default]
61    Default,
62    Round,
63    Circle,
64}
65
66/// Icon placement relative to content.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum ButtonIconPlacement {
69    #[default]
70    Start,
71    End,
72}
73
74/// Native button `type` attribute.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub enum ButtonHtmlType {
77    #[default]
78    Button,
79    Submit,
80    Reset,
81}
82
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84struct ButtonGroupContext {
85    size: Option<ButtonSize>,
86    shape: Option<ButtonShape>,
87    color: Option<ButtonColor>,
88    variant: Option<ButtonVariant>,
89}
90
91/// Container that forwards size/variant hints to child buttons.
92#[derive(Props, Clone, PartialEq)]
93pub struct ButtonGroupProps {
94    #[props(optional)]
95    pub size: Option<ButtonSize>,
96    #[props(optional)]
97    pub shape: Option<ButtonShape>,
98    #[props(optional)]
99    pub color: Option<ButtonColor>,
100    #[props(optional)]
101    pub variant: Option<ButtonVariant>,
102    #[props(optional)]
103    pub class: Option<String>,
104    #[props(optional)]
105    pub style: Option<String>,
106    pub children: Element,
107}
108
109/// Wrap multiple buttons with shared styling hints.
110#[component]
111pub fn ButtonGroup(props: ButtonGroupProps) -> Element {
112    let ButtonGroupProps {
113        size,
114        shape,
115        color,
116        variant,
117        class,
118        style,
119        children,
120    } = props;
121    use_context_provider(|| ButtonGroupContext {
122        size,
123        shape,
124        color,
125        variant,
126    });
127    let mut class_list = vec!["adui-btn-group".to_string()];
128    if let Some(extra) = class {
129        class_list.push(extra);
130    }
131    let class_attr = class_list.join(" ");
132    let style_attr = style.unwrap_or_default();
133    rsx! {
134        div {
135            class: "{class_attr}",
136            style: "{style_attr}",
137            {children}
138        }
139    }
140}
141
142/// Props for the Ant Design flavored button.
143///
144/// Note: The `loading` property in Ant Design TypeScript supports an object form
145/// `{ delay?: number, icon?: ReactNode }`. In this Rust implementation, it is split
146/// into `loading` (boolean), `loading_delay` (Option<u64>), and `loading_icon` (Option<Element>)
147/// for better type safety and clarity.
148#[derive(Props, Clone, PartialEq)]
149pub struct ButtonProps {
150    #[props(default)]
151    pub r#type: ButtonType,
152    #[props(default)]
153    pub size: ButtonSize,
154    #[props(default)]
155    pub shape: ButtonShape,
156    #[props(default)]
157    pub danger: bool,
158    #[props(default)]
159    pub ghost: bool,
160    #[props(default)]
161    pub block: bool,
162    /// Whether the button is in loading state.
163    /// Note: In Ant Design TypeScript, this can be a boolean or an object `{ delay?: number, icon?: ReactNode }`.
164    /// Here it is split into `loading`, `loading_delay`, and `loading_icon` for better type safety.
165    #[props(default)]
166    pub loading: bool,
167    /// Optional loading delay in milliseconds before showing spinner.
168    #[props(optional)]
169    pub loading_delay: Option<u64>,
170    /// Custom loading icon.
171    #[props(optional)]
172    pub loading_icon: Option<Element>,
173    /// If true, inserts a space between two CJK chars (antd behavior).
174    #[props(default = true)]
175    pub auto_insert_space: bool,
176    /// Optional label text used for auto spacing/icon-only detection; if set, overrides children text for button content.
177    #[props(optional)]
178    pub label: Option<String>,
179    /// Mark as icon-only (adds class); if unset, derives from `label` being empty with an icon.
180    #[props(optional)]
181    pub icon_only: Option<bool>,
182    #[props(default)]
183    pub disabled: bool,
184    #[props(optional)]
185    pub color: Option<ButtonColor>,
186    #[props(optional)]
187    pub variant: Option<ButtonVariant>,
188    /// Icon placement relative to content.
189    #[props(default)]
190    pub icon_placement: ButtonIconPlacement,
191    /// @deprecated Please use `icon_placement` instead.
192    /// This property is kept for backward compatibility and will be mapped to `icon_placement`.
193    #[props(optional)]
194    pub icon_position: Option<ButtonIconPlacement>,
195    #[props(optional)]
196    pub icon: Option<Element>,
197    #[props(optional)]
198    pub href: Option<String>,
199    #[props(optional)]
200    pub class: Option<String>,
201    /// Extra class applied to button element (aligned to antd classNames.root).
202    #[props(optional)]
203    pub class_names_root: Option<String>,
204    /// Extra class applied to icon span.
205    #[props(optional)]
206    pub class_names_icon: Option<String>,
207    /// Extra class applied to content span.
208    #[props(optional)]
209    pub class_names_content: Option<String>,
210    /// Extra inline style applied to root.
211    #[props(optional)]
212    pub styles_root: Option<String>,
213    /// Native button type, used when rendering as `<button>`.
214    #[props(default)]
215    pub html_type: ButtonHtmlType,
216    /// Data attributes as a map of key-value pairs. Keys should be without the "data-" prefix.
217    /// For example, `data_attributes: Some([("test", "value")])` will render as `data-test="value"`.
218    #[props(optional)]
219    pub data_attributes: Option<Vec<(String, String)>>,
220    #[props(optional)]
221    pub onclick: Option<EventHandler<MouseEvent>>,
222    pub children: Element,
223}
224
225/// Ant Design inspired button implementation for Dioxus.
226#[component]
227pub fn Button(props: ButtonProps) -> Element {
228    let ButtonProps {
229        r#type,
230        size,
231        shape,
232        danger,
233        ghost,
234        block,
235        loading,
236        loading_delay,
237        loading_icon,
238        auto_insert_space,
239        label,
240        icon_only,
241        disabled,
242        color,
243        variant,
244        icon_placement,
245        icon_position,
246        icon,
247        href,
248        class,
249        class_names_root,
250        class_names_icon,
251        class_names_content,
252        styles_root,
253        html_type,
254        data_attributes,
255        onclick,
256        children,
257    } = props;
258
259    // Handle deprecated icon_position: map to icon_placement if provided
260    let icon_placement = icon_position.unwrap_or(icon_placement);
261
262    // Merge size/shape/variant/color from ButtonGroup and global ConfigProvider.
263    let mut size = size;
264    let mut shape = shape;
265    let mut variant = variant;
266    let mut color = color;
267
268    if let Some(ctx) = try_use_context::<ButtonGroupContext>() {
269        if let Some(shared_size) = ctx.size {
270            size = shared_size;
271        }
272        if let Some(shared_shape) = ctx.shape {
273            shape = shared_shape;
274        }
275        if variant.is_none() {
276            variant = ctx.variant;
277        }
278        if color.is_none() {
279            color = ctx.color;
280        }
281    } else if matches!(size, ButtonSize::Middle) {
282        // Fall back to global ConfigProvider size when not inside a ButtonGroup
283        // and the user did not explicitly specify size.
284        let cfg = use_config();
285        size = ButtonSize::from_global(cfg.size);
286    }
287
288    let theme = use_theme();
289
290    // Derive variant/color from legacy type/danger if not provided.
291    let derived_variant = variant.unwrap_or(match r#type {
292        ButtonType::Primary => ButtonVariant::Solid,
293        ButtonType::Dashed => ButtonVariant::Dashed,
294        ButtonType::Text => ButtonVariant::Text,
295        ButtonType::Link => ButtonVariant::Link,
296        ButtonType::Default => ButtonVariant::Outlined,
297    });
298    let derived_color = color.unwrap_or({
299        if danger {
300            ButtonColor::Danger
301        } else {
302            match r#type {
303                ButtonType::Primary => ButtonColor::Primary,
304                _ => ButtonColor::Default,
305            }
306        }
307    });
308
309    let html_type_attr = match html_type {
310        ButtonHtmlType::Button => "button",
311        ButtonHtmlType::Submit => "submit",
312        ButtonHtmlType::Reset => "reset",
313    };
314
315    // Loading delay handling: debounce before showing spinner.
316    let inner_loading = use_signal(|| loading);
317    {
318        let mut state = inner_loading;
319        let delay_ms = loading_delay.unwrap_or(0);
320        use_effect(move || {
321            if loading {
322                if delay_ms == 0 {
323                    state.set(true);
324                } else {
325                    let mut delayed_state = state;
326                    let delay = delay_ms;
327                    // Fallback: block current task; avoids Send requirement on signals.
328                    std::thread::sleep(std::time::Duration::from_millis(delay));
329                    delayed_state.set(true);
330                }
331            } else {
332                state.set(false);
333            }
334        });
335    }
336
337    let tokens = theme.tokens();
338    let visuals = visuals(&tokens, derived_variant, derived_color, ghost);
339    let metrics = metrics(&tokens, size, shape);
340
341    let disabled = disabled || *inner_loading.read();
342    let mut class_list = vec!["adui-btn".to_string()];
343    class_list.push(match derived_variant {
344        ButtonVariant::Solid => "adui-btn-solid".into(),
345        ButtonVariant::Outlined => "adui-btn-outlined".into(),
346        ButtonVariant::Dashed => "adui-btn-dashed".into(),
347        ButtonVariant::Text => "adui-btn-text".into(),
348        ButtonVariant::Link => "adui-btn-link".into(),
349    });
350    class_list.push(match derived_color {
351        ButtonColor::Primary => "adui-btn-primary".into(),
352        ButtonColor::Success => "adui-btn-success".into(),
353        ButtonColor::Warning => "adui-btn-warning".into(),
354        ButtonColor::Danger => "adui-btn-danger".into(),
355        ButtonColor::Default => "adui-btn-default".into(),
356    });
357    if block {
358        class_list.push("adui-btn-block".into());
359    }
360    if ghost {
361        class_list.push("adui-btn-ghost".into());
362    }
363    if disabled {
364        class_list.push("adui-btn-disabled".into());
365    }
366    if *inner_loading.read() {
367        class_list.push("adui-btn-loading".into());
368    }
369    if let Some(extra) = class.as_ref() {
370        class_list.push(extra.clone());
371    }
372    if let Some(extra) = class_names_root.as_ref() {
373        class_list.push(extra.clone());
374    }
375    let icon_only_flag = icon_only.unwrap_or_else(|| {
376        label.as_ref().map(|s| s.trim().is_empty()).unwrap_or(false) && icon.is_some()
377    });
378    if icon_only_flag {
379        class_list.push("adui-btn-icon-only".into());
380    }
381    let class_attr = class_list.join(" ");
382
383    let style = format!(
384        "--adui-btn-bg:{};--adui-btn-bg-hover:{};--adui-btn-bg-active:{};\
385        --adui-btn-color:{};--adui-btn-color-hover:{};--adui-btn-color-active:{};\
386        --adui-btn-border:{};--adui-btn-border-hover:{};--adui-btn-border-active:{};\
387        --adui-btn-border-style:{};\
388        --adui-btn-font-size:{}px;\
389        --adui-btn-radius:{}px;\
390        --adui-btn-height:{}px;\
391        --adui-btn-padding-block:{}px;\
392        --adui-btn-padding-inline:{}px;\
393        --adui-btn-shadow:{};\
394        --adui-btn-focus-shadow:{};",
395        visuals.bg,
396        visuals.bg_hover,
397        visuals.bg_active,
398        visuals.color,
399        visuals.color_hover,
400        visuals.color_active,
401        visuals.border,
402        visuals.border_hover,
403        visuals.border_active,
404        visuals.border_style,
405        metrics.font_size,
406        metrics.radius,
407        metrics.height,
408        metrics.padding_block,
409        metrics.padding_inline,
410        visuals.shadow,
411        visuals.focus_shadow
412    );
413
414    let spinner = loading_icon.unwrap_or_else(|| {
415        rsx!(span {
416            class: "adui-btn-spinner adui-btn-icon"
417        })
418    });
419    let mut icon_class = "adui-btn-icon".to_string();
420    if let Some(extra) = class_names_icon.as_ref() {
421        icon_class.push(' ');
422        icon_class.push_str(extra);
423    }
424    let mut content_class = "adui-btn-content".to_string();
425    if let Some(extra) = class_names_content.as_ref() {
426        content_class.push(' ');
427        content_class.push_str(extra);
428    }
429    let mut content_text = label.clone();
430    if let Some(text) = content_text.as_mut()
431        && auto_insert_space
432        && is_two_cjk(text)
433    {
434        let mut chars = text.chars();
435        let first = chars.next().unwrap_or_default();
436        let second = chars.next().unwrap_or_default();
437        *text = format!("{} {}", first, second);
438    }
439
440    let icon_node = icon.map(|node| {
441        let cls = icon_class.clone();
442        rsx!(span { class: "{cls}", {node} })
443    });
444
445    let contents = match icon_placement {
446        ButtonIconPlacement::Start => rsx! {
447            if *inner_loading.read() {
448                {spinner.clone()}
449            } else if let Some(icon_el) = icon_node.clone() {
450                {icon_el}
451            }
452            span { class: "{content_class}",
453                if let Some(text) = content_text.clone() {
454                    "{text}"
455                } else {
456                    {children.clone()}
457                }
458            }
459        },
460        ButtonIconPlacement::End => rsx! {
461            span { class: "{content_class}",
462                if let Some(text) = content_text.clone() {
463                    "{text}"
464                } else {
465                    {children.clone()}
466                }
467            }
468            if *inner_loading.read() {
469                {spinner.clone()}
470            } else if let Some(icon_el) = icon_node.clone() {
471                {icon_el}
472            }
473        },
474    };
475
476    // Generate a unique ID for this button to support data-* attributes via JavaScript interop
477    let button_id = use_signal(|| format!("adui-btn-{}", rand_id()));
478
479    // Set data-* attributes via JavaScript interop if provided
480    #[cfg(target_arch = "wasm32")]
481    {
482        if let Some(data_attrs) = data_attributes.as_ref() {
483            let id = button_id.read().clone();
484            let attrs = data_attrs.clone();
485            {
486                use_effect(move || {
487                    use wasm_bindgen::JsCast;
488                    if let Some(window) = web_sys::window() {
489                        if let Some(document) = window.document() {
490                            if let Some(element) = document.get_element_by_id(&id) {
491                                for (key, value) in attrs.iter() {
492                                    let attr_name = format!("data-{}", key);
493                                    let _ = element.set_attribute(&attr_name, value);
494                                }
495                            }
496                        }
497                    }
498                });
499            }
500        }
501    }
502    #[cfg(not(target_arch = "wasm32"))]
503    {
504        // Suppress unused variable warning on non-wasm32 targets
505        let _ = data_attributes;
506    }
507
508    if let Some(href) = href {
509        let handler = onclick;
510        let id_val = button_id.read().clone();
511        return rsx! {
512            a {
513                id: "{id_val}",
514                class: "{class_attr}",
515                style: format!("{style}{}", styles_root.clone().unwrap_or_default()),
516                href: "{href}",
517                role: "button",
518                "aria-disabled": disabled,
519                "aria-busy": *inner_loading.read(),
520                tabindex: if disabled { "-1" } else { "0" },
521                onclick: move |evt| {
522                    if disabled || *inner_loading.read() {
523                        evt.stop_propagation();
524                        evt.prevent_default();
525                        return;
526                    }
527                    if let Some(h) = handler.as_ref() {
528                        h.call(evt);
529                    }
530                },
531                {contents}
532            }
533        };
534    }
535
536    let handler = onclick;
537    let id_val = button_id.read().clone();
538    rsx! {
539        button {
540            id: "{id_val}",
541            class: "{class_attr}",
542            style: format!("{style}{}", styles_root.unwrap_or_default()),
543            r#type: "{html_type_attr}",
544            role: "button",
545            disabled: disabled,
546            "aria-disabled": disabled,
547            "aria-busy": *inner_loading.read(),
548            onclick: move |evt| {
549                if disabled || *inner_loading.read() {
550                    evt.stop_propagation();
551                    return;
552                }
553                if let Some(h) = handler.as_ref() {
554                    h.call(evt);
555                }
556            },
557            {contents}
558        }
559    }
560}
561
562struct ButtonVisuals {
563    bg: String,
564    bg_hover: String,
565    bg_active: String,
566    color: String,
567    color_hover: String,
568    color_active: String,
569    border: String,
570    border_hover: String,
571    border_active: String,
572    border_style: String,
573    shadow: String,
574    focus_shadow: String,
575}
576
577struct ButtonMetrics {
578    height: f32,
579    padding_block: f32,
580    padding_inline: f32,
581    radius: f32,
582    font_size: f32,
583}
584
585fn metrics(tokens: &ThemeTokens, size: ButtonSize, shape: ButtonShape) -> ButtonMetrics {
586    let (height, padding_block, padding_inline, font_size) = match size {
587        ButtonSize::Small => (
588            tokens.control_height_small,
589            tokens.padding_block - 2.0,
590            tokens.padding_inline - 4.0,
591            tokens.font_size - 1.0,
592        ),
593        ButtonSize::Large => (
594            tokens.control_height_large,
595            tokens.padding_block + 2.0,
596            tokens.padding_inline + 2.0,
597            tokens.font_size + 1.0,
598        ),
599        ButtonSize::Middle => (
600            tokens.control_height,
601            tokens.padding_block,
602            tokens.padding_inline,
603            tokens.font_size,
604        ),
605    };
606
607    let radius = match shape {
608        ButtonShape::Circle => height / 2.0,
609        ButtonShape::Round => (height / 2.0).max(tokens.border_radius),
610        ButtonShape::Default => tokens.border_radius,
611    };
612
613    ButtonMetrics {
614        height,
615        padding_block,
616        padding_inline,
617        radius,
618        font_size,
619    }
620}
621
622fn is_two_cjk(text: &str) -> bool {
623    let mut chars = text.chars();
624    let first = chars.next();
625    let second = chars.next();
626    second.is_some()
627        && chars.next().is_none()
628        && first.map(is_cjk).unwrap_or(false)
629        && second.map(is_cjk).unwrap_or(false)
630}
631
632fn is_cjk(ch: char) -> bool {
633    matches!(ch as u32,
634        0x4E00..=0x9FFF // CJK Unified Ideographs
635        | 0x3400..=0x4DBF // Extension A
636        | 0x20000..=0x2A6DF // Extension B
637        | 0x2A700..=0x2B73F
638        | 0x2B740..=0x2B81F
639        | 0x2B820..=0x2CEAF
640        | 0xF900..=0xFAFF // Compatibility Ideographs
641    )
642}
643
644fn visuals(
645    tokens: &ThemeTokens,
646    variant: ButtonVariant,
647    color: ButtonColor,
648    ghost: bool,
649) -> ButtonVisuals {
650    match variant {
651        ButtonVariant::Solid => solid_visuals(tokens, color, ghost),
652        ButtonVariant::Link => link_visuals(tokens, color),
653        ButtonVariant::Text => text_visuals(tokens, color),
654        ButtonVariant::Dashed | ButtonVariant::Outlined => outline_visuals(
655            tokens,
656            color,
657            ghost,
658            matches!(variant, ButtonVariant::Dashed),
659        ),
660    }
661}
662
663fn solid_visuals(tokens: &ThemeTokens, color: ButtonColor, ghost: bool) -> ButtonVisuals {
664    let mut visuals = match color {
665        ButtonColor::Primary
666        | ButtonColor::Success
667        | ButtonColor::Warning
668        | ButtonColor::Danger => {
669            let (accent, hover, active) = tone_palette(tokens, color);
670            ButtonVisuals {
671                bg: accent.clone(),
672                bg_hover: hover.clone(),
673                bg_active: active.clone(),
674                color: "#ffffff".into(),
675                color_hover: "#ffffff".into(),
676                color_active: "#ffffff".into(),
677                border: accent.clone(),
678                border_hover: hover.clone(),
679                border_active: active.clone(),
680                border_style: "solid".into(),
681                shadow: tokens.shadow.clone(),
682                focus_shadow: focus_ring(color, "0 0 0 2px rgba(22, 119, 255, 0.28)"),
683            }
684        }
685        ButtonColor::Default => ButtonVisuals {
686            bg: tokens.color_bg_container.clone(),
687            bg_hover: tokens.color_bg_base.clone(),
688            bg_active: tokens.color_bg_base.clone(),
689            color: tokens.color_text.clone(),
690            color_hover: tokens.color_text.clone(),
691            color_active: tokens.color_text.clone(),
692            border: tokens.color_border.clone(),
693            border_hover: tokens.color_border_hover.clone(),
694            border_active: tokens.color_border_hover.clone(),
695            border_style: "solid".into(),
696            shadow: tokens.shadow.clone(),
697            focus_shadow: "0 0 0 2px rgba(0, 0, 0, 0.08)".into(),
698        },
699    };
700
701    if ghost {
702        visuals.bg = "transparent".into();
703        visuals.bg_hover = "transparent".into();
704        visuals.bg_active = "transparent".into();
705        visuals.color = visuals.border.clone();
706        visuals.color_hover = visuals.border_hover.clone();
707        visuals.color_active = visuals.border_active.clone();
708        visuals.shadow = "none".into();
709    }
710
711    visuals
712}
713
714fn link_visuals(tokens: &ThemeTokens, color: ButtonColor) -> ButtonVisuals {
715    let (accent, hover, active) = if color == ButtonColor::Default {
716        (
717            tokens.color_link.clone(),
718            tokens.color_link_hover.clone(),
719            tokens.color_link_active.clone(),
720        )
721    } else {
722        tone_palette(tokens, color)
723    };
724
725    ButtonVisuals {
726        bg: "transparent".into(),
727        bg_hover: "transparent".into(),
728        bg_active: "transparent".into(),
729        color: accent.clone(),
730        color_hover: hover.clone(),
731        color_active: active.clone(),
732        border: "transparent".into(),
733        border_hover: "transparent".into(),
734        border_active: "transparent".into(),
735        border_style: "solid".into(),
736        shadow: "none".into(),
737        focus_shadow: focus_ring(color, "0 0 0 2px rgba(22, 119, 255, 0.16)"),
738    }
739}
740
741fn text_visuals(tokens: &ThemeTokens, color: ButtonColor) -> ButtonVisuals {
742    let (accent, hover, active) = match color {
743        ButtonColor::Default => (
744            tokens.color_text.clone(),
745            tokens.color_primary.clone(),
746            tokens.color_primary_active.clone(),
747        ),
748        _ => tone_palette(tokens, color),
749    };
750
751    ButtonVisuals {
752        bg: "transparent".into(),
753        bg_hover: "rgba(0,0,0,0.03)".into(),
754        bg_active: "rgba(0,0,0,0.06)".into(),
755        color: accent.clone(),
756        color_hover: hover.clone(),
757        color_active: active.clone(),
758        border: "transparent".into(),
759        border_hover: "transparent".into(),
760        border_active: "transparent".into(),
761        border_style: "solid".into(),
762        shadow: "none".into(),
763        focus_shadow: focus_ring(color, "0 0 0 2px rgba(22, 119, 255, 0.12)"),
764    }
765}
766
767fn outline_visuals(
768    tokens: &ThemeTokens,
769    color: ButtonColor,
770    ghost: bool,
771    dashed: bool,
772) -> ButtonVisuals {
773    let mut visuals = match color {
774        ButtonColor::Default => ButtonVisuals {
775            bg: tokens.color_bg_container.clone(),
776            bg_hover: tokens.color_bg_container.clone(),
777            bg_active: tokens.color_bg_container.clone(),
778            color: tokens.color_text.clone(),
779            color_hover: tokens.color_primary.clone(),
780            color_active: tokens.color_primary_active.clone(),
781            border: tokens.color_border.clone(),
782            border_hover: tokens.color_primary.clone(),
783            border_active: tokens.color_primary_active.clone(),
784            border_style: if dashed {
785                "dashed".into()
786            } else {
787                "solid".into()
788            },
789            shadow: "none".into(),
790            focus_shadow: "0 0 0 2px rgba(22, 119, 255, 0.12)".into(),
791        },
792        _ => {
793            let (accent, hover, active) = tone_palette(tokens, color);
794            ButtonVisuals {
795                bg: tokens.color_bg_container.clone(),
796                bg_hover: tokens.color_bg_container.clone(),
797                bg_active: tokens.color_bg_container.clone(),
798                color: accent.clone(),
799                color_hover: hover.clone(),
800                color_active: active.clone(),
801                border: accent.clone(),
802                border_hover: hover.clone(),
803                border_active: active.clone(),
804                border_style: if dashed {
805                    "dashed".into()
806                } else {
807                    "solid".into()
808                },
809                shadow: "none".into(),
810                focus_shadow: focus_ring(color, "0 0 0 2px rgba(22, 119, 255, 0.15)"),
811            }
812        }
813    };
814
815    if ghost {
816        visuals.bg = "transparent".into();
817        visuals.bg_hover = "transparent".into();
818        visuals.bg_active = "transparent".into();
819    }
820
821    visuals
822}
823
824fn tone_palette(tokens: &ThemeTokens, color: ButtonColor) -> (String, String, String) {
825    match color {
826        ButtonColor::Primary => (
827            tokens.color_primary.clone(),
828            tokens.color_primary_hover.clone(),
829            tokens.color_primary_active.clone(),
830        ),
831        ButtonColor::Success => (
832            tokens.color_success.clone(),
833            tokens.color_success_hover.clone(),
834            tokens.color_success_active.clone(),
835        ),
836        ButtonColor::Warning => (
837            tokens.color_warning.clone(),
838            tokens.color_warning_hover.clone(),
839            tokens.color_warning_active.clone(),
840        ),
841        ButtonColor::Danger => (
842            tokens.color_error.clone(),
843            tokens.color_error_hover.clone(),
844            tokens.color_error_active.clone(),
845        ),
846        ButtonColor::Default => (
847            tokens.color_text.clone(),
848            tokens.color_text_muted.clone(),
849            tokens.color_text_secondary.clone(),
850        ),
851    }
852}
853
854fn focus_ring(color: ButtonColor, fallback: &str) -> String {
855    match color {
856        ButtonColor::Primary => "0 0 0 2px rgba(22, 119, 255, 0.28)".into(),
857        ButtonColor::Success => "0 0 0 2px rgba(82, 196, 26, 0.26)".into(),
858        ButtonColor::Warning => "0 0 0 2px rgba(250, 173, 20, 0.26)".into(),
859        ButtonColor::Danger => "0 0 0 2px rgba(255, 77, 79, 0.26)".into(),
860        ButtonColor::Default => fallback.into(),
861    }
862}
863
864/// Generate a simple random ID for element identification.
865fn rand_id() -> u32 {
866    // Simple pseudo-random based on current time
867    #[cfg(target_arch = "wasm32")]
868    {
869        use js_sys::Math;
870        (Math::random() * 1_000_000.0) as u32
871    }
872
873    #[cfg(not(target_arch = "wasm32"))]
874    {
875        use std::time::{SystemTime, UNIX_EPOCH};
876        SystemTime::now()
877            .duration_since(UNIX_EPOCH)
878            .map(|d| d.subsec_nanos())
879            .unwrap_or(0)
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use crate::theme::ThemeTokens;
887
888    #[test]
889    fn metrics_respect_size_and_shape() {
890        let tokens = ThemeTokens::light();
891        let circle = metrics(&tokens, ButtonSize::Small, ButtonShape::Circle);
892        assert_eq!(circle.height, tokens.control_height_small);
893        assert!((circle.radius - circle.height / 2.0).abs() < f32::EPSILON);
894
895        let round = metrics(&tokens, ButtonSize::Large, ButtonShape::Round);
896        assert!(round.radius >= tokens.border_radius);
897        assert!(round.padding_inline > circle.padding_inline);
898    }
899
900    #[test]
901    fn detects_two_cjk_characters() {
902        assert!(is_two_cjk("按钮"));
903        assert!(!is_two_cjk("按钮A"));
904        assert!(!is_two_cjk("btn"));
905    }
906
907    #[test]
908    fn visuals_follow_variant_and_tone_rules() {
909        let tokens = ThemeTokens::light();
910        let solid = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Primary, false);
911        assert_eq!(solid.bg, tokens.color_primary);
912        assert_eq!(solid.color, "#ffffff");
913
914        let ghost = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Primary, true);
915        assert_eq!(ghost.bg, "transparent");
916        assert_eq!(ghost.color, ghost.border);
917
918        let link_style = visuals(&tokens, ButtonVariant::Link, ButtonColor::Default, false);
919        assert_eq!(link_style.bg, "transparent");
920        assert_eq!(link_style.border, "transparent");
921        assert_eq!(link_style.color, tokens.color_link);
922    }
923
924    #[test]
925    fn button_size_from_global() {
926        use crate::components::config_provider::ComponentSize;
927        assert_eq!(
928            ButtonSize::from_global(ComponentSize::Small),
929            ButtonSize::Small
930        );
931        assert_eq!(
932            ButtonSize::from_global(ComponentSize::Middle),
933            ButtonSize::Middle
934        );
935        assert_eq!(
936            ButtonSize::from_global(ComponentSize::Large),
937            ButtonSize::Large
938        );
939    }
940
941    #[test]
942    fn button_size_all_variants() {
943        assert_eq!(ButtonSize::Small, ButtonSize::Small);
944        assert_eq!(ButtonSize::Middle, ButtonSize::Middle);
945        assert_eq!(ButtonSize::Large, ButtonSize::Large);
946        assert_ne!(ButtonSize::Small, ButtonSize::Large);
947    }
948
949    #[test]
950    fn button_size_default() {
951        assert_eq!(ButtonSize::default(), ButtonSize::Middle);
952    }
953
954    #[test]
955    fn button_shape_all_variants() {
956        assert_eq!(ButtonShape::Default, ButtonShape::Default);
957        assert_eq!(ButtonShape::Round, ButtonShape::Round);
958        assert_eq!(ButtonShape::Circle, ButtonShape::Circle);
959        assert_ne!(ButtonShape::Default, ButtonShape::Circle);
960    }
961
962    #[test]
963    fn button_shape_default() {
964        assert_eq!(ButtonShape::default(), ButtonShape::Default);
965    }
966
967    #[test]
968    fn button_type_all_variants() {
969        assert_eq!(ButtonType::Default, ButtonType::Default);
970        assert_eq!(ButtonType::Primary, ButtonType::Primary);
971        assert_eq!(ButtonType::Dashed, ButtonType::Dashed);
972        assert_eq!(ButtonType::Text, ButtonType::Text);
973        assert_eq!(ButtonType::Link, ButtonType::Link);
974        assert_ne!(ButtonType::Default, ButtonType::Primary);
975    }
976
977    #[test]
978    fn button_type_default() {
979        assert_eq!(ButtonType::default(), ButtonType::Default);
980    }
981
982    #[test]
983    fn button_color_all_variants() {
984        assert_eq!(ButtonColor::Default, ButtonColor::Default);
985        assert_eq!(ButtonColor::Primary, ButtonColor::Primary);
986        assert_eq!(ButtonColor::Success, ButtonColor::Success);
987        assert_eq!(ButtonColor::Warning, ButtonColor::Warning);
988        assert_eq!(ButtonColor::Danger, ButtonColor::Danger);
989        assert_ne!(ButtonColor::Default, ButtonColor::Primary);
990    }
991
992    #[test]
993    fn button_color_default() {
994        assert_eq!(ButtonColor::default(), ButtonColor::Default);
995    }
996
997    #[test]
998    fn button_variant_all_variants() {
999        assert_eq!(ButtonVariant::Solid, ButtonVariant::Solid);
1000        assert_eq!(ButtonVariant::Outlined, ButtonVariant::Outlined);
1001        assert_eq!(ButtonVariant::Dashed, ButtonVariant::Dashed);
1002        assert_eq!(ButtonVariant::Text, ButtonVariant::Text);
1003        assert_eq!(ButtonVariant::Link, ButtonVariant::Link);
1004        assert_ne!(ButtonVariant::Solid, ButtonVariant::Outlined);
1005    }
1006
1007    #[test]
1008    fn button_variant_default() {
1009        assert_eq!(ButtonVariant::default(), ButtonVariant::Outlined);
1010    }
1011
1012    #[test]
1013    fn button_icon_placement_all_variants() {
1014        assert_eq!(ButtonIconPlacement::Start, ButtonIconPlacement::Start);
1015        assert_eq!(ButtonIconPlacement::End, ButtonIconPlacement::End);
1016        assert_ne!(ButtonIconPlacement::Start, ButtonIconPlacement::End);
1017    }
1018
1019    #[test]
1020    fn button_icon_placement_default() {
1021        assert_eq!(ButtonIconPlacement::default(), ButtonIconPlacement::Start);
1022    }
1023
1024    #[test]
1025    fn button_html_type_all_variants() {
1026        assert_eq!(ButtonHtmlType::Button, ButtonHtmlType::Button);
1027        assert_eq!(ButtonHtmlType::Submit, ButtonHtmlType::Submit);
1028        assert_eq!(ButtonHtmlType::Reset, ButtonHtmlType::Reset);
1029        assert_ne!(ButtonHtmlType::Button, ButtonHtmlType::Submit);
1030    }
1031
1032    #[test]
1033    fn button_html_type_default() {
1034        assert_eq!(ButtonHtmlType::default(), ButtonHtmlType::Button);
1035    }
1036
1037    #[test]
1038    fn metrics_all_sizes() {
1039        let tokens = ThemeTokens::light();
1040        let small = metrics(&tokens, ButtonSize::Small, ButtonShape::Default);
1041        let middle = metrics(&tokens, ButtonSize::Middle, ButtonShape::Default);
1042        let large = metrics(&tokens, ButtonSize::Large, ButtonShape::Default);
1043
1044        assert!(small.height < middle.height);
1045        assert!(middle.height < large.height);
1046        assert!(small.font_size < middle.font_size);
1047        assert!(middle.font_size < large.font_size);
1048    }
1049
1050    #[test]
1051    fn metrics_all_shapes() {
1052        let tokens = ThemeTokens::light();
1053        let default = metrics(&tokens, ButtonSize::Middle, ButtonShape::Default);
1054        let round = metrics(&tokens, ButtonSize::Middle, ButtonShape::Round);
1055        let circle = metrics(&tokens, ButtonSize::Middle, ButtonShape::Circle);
1056
1057        assert_eq!(default.radius, tokens.border_radius);
1058        assert!(round.radius >= tokens.border_radius);
1059        assert!((circle.radius - default.height / 2.0).abs() < f32::EPSILON);
1060    }
1061
1062    #[test]
1063    fn is_cjk_detects_cjk_characters() {
1064        assert!(is_cjk('中'));
1065        assert!(is_cjk('文'));
1066        assert!(is_cjk('日'));
1067        assert!(is_cjk('本'));
1068        assert!(!is_cjk('A'));
1069        assert!(!is_cjk('1'));
1070        assert!(!is_cjk(' '));
1071    }
1072
1073    #[test]
1074    fn is_two_cjk_edge_cases() {
1075        assert!(!is_two_cjk(""));
1076        assert!(!is_two_cjk("中"));
1077        assert!(is_two_cjk("中文"));
1078        assert!(!is_two_cjk("中文A"));
1079        assert!(!is_two_cjk("A中文"));
1080    }
1081
1082    #[test]
1083    fn visuals_all_colors() {
1084        let tokens = ThemeTokens::light();
1085        let primary = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Primary, false);
1086        let success = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Success, false);
1087        let warning = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Warning, false);
1088        let danger = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Danger, false);
1089        let default = visuals(&tokens, ButtonVariant::Solid, ButtonColor::Default, false);
1090
1091        assert_eq!(primary.color, "#ffffff");
1092        assert_eq!(success.color, "#ffffff");
1093        assert_eq!(warning.color, "#ffffff");
1094        assert_eq!(danger.color, "#ffffff");
1095        assert_ne!(default.color, "#ffffff");
1096    }
1097
1098    #[test]
1099    fn visuals_text_variant() {
1100        let tokens = ThemeTokens::light();
1101        let text = visuals(&tokens, ButtonVariant::Text, ButtonColor::Default, false);
1102        assert_eq!(text.bg, "transparent");
1103        assert_eq!(text.border, "transparent");
1104        assert_eq!(text.shadow, "none");
1105    }
1106
1107    #[test]
1108    fn visuals_dashed_variant() {
1109        let tokens = ThemeTokens::light();
1110        let dashed = visuals(&tokens, ButtonVariant::Dashed, ButtonColor::Default, false);
1111        assert_eq!(dashed.border_style, "dashed");
1112    }
1113
1114    #[test]
1115    fn visuals_outlined_variant() {
1116        let tokens = ThemeTokens::light();
1117        let outlined = visuals(
1118            &tokens,
1119            ButtonVariant::Outlined,
1120            ButtonColor::Default,
1121            false,
1122        );
1123        assert_eq!(outlined.border_style, "solid");
1124    }
1125
1126    #[test]
1127    fn tone_palette_all_colors() {
1128        let tokens = ThemeTokens::light();
1129        let (primary, _, _) = tone_palette(&tokens, ButtonColor::Primary);
1130        let (success, _, _) = tone_palette(&tokens, ButtonColor::Success);
1131        let (warning, _, _) = tone_palette(&tokens, ButtonColor::Warning);
1132        let (danger, _, _) = tone_palette(&tokens, ButtonColor::Danger);
1133
1134        assert_eq!(primary, tokens.color_primary);
1135        assert_eq!(success, tokens.color_success);
1136        assert_eq!(warning, tokens.color_warning);
1137        assert_eq!(danger, tokens.color_error);
1138    }
1139
1140    #[test]
1141    fn focus_ring_all_colors() {
1142        let primary = focus_ring(ButtonColor::Primary, "");
1143        let success = focus_ring(ButtonColor::Success, "");
1144        let warning = focus_ring(ButtonColor::Warning, "");
1145        let danger = focus_ring(ButtonColor::Danger, "");
1146        let default = focus_ring(ButtonColor::Default, "fallback");
1147
1148        assert!(primary.contains("255"));
1149        assert!(success.contains("26"));
1150        assert!(warning.contains("173"));
1151        assert!(danger.contains("79"));
1152        assert_eq!(default, "fallback");
1153    }
1154}