1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::theme::{ThemeTokens, use_theme};
3use dioxus::prelude::*;
4
5#[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#[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#[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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum ButtonShape {
60 #[default]
61 Default,
62 Round,
63 Circle,
64}
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum ButtonIconPlacement {
69 #[default]
70 Start,
71 End,
72}
73
74#[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#[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#[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#[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 #[props(default)]
166 pub loading: bool,
167 #[props(optional)]
169 pub loading_delay: Option<u64>,
170 #[props(optional)]
172 pub loading_icon: Option<Element>,
173 #[props(default = true)]
175 pub auto_insert_space: bool,
176 #[props(optional)]
178 pub label: Option<String>,
179 #[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 #[props(default)]
190 pub icon_placement: ButtonIconPlacement,
191 #[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 #[props(optional)]
203 pub class_names_root: Option<String>,
204 #[props(optional)]
206 pub class_names_icon: Option<String>,
207 #[props(optional)]
209 pub class_names_content: Option<String>,
210 #[props(optional)]
212 pub styles_root: Option<String>,
213 #[props(default)]
215 pub html_type: ButtonHtmlType,
216 #[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#[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 let icon_placement = icon_position.unwrap_or(icon_placement);
261
262 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 let cfg = use_config();
285 size = ButtonSize::from_global(cfg.size);
286 }
287
288 let theme = use_theme();
289
290 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 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 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 let button_id = use_signal(|| format!("adui-btn-{}", rand_id()));
478
479 #[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 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 | 0x3400..=0x4DBF | 0x20000..=0x2A6DF | 0x2A700..=0x2B73F
638 | 0x2B740..=0x2B81F
639 | 0x2B820..=0x2CEAF
640 | 0xF900..=0xFAFF )
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
864fn rand_id() -> u32 {
866 #[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}